diff --git a/.eslintrc.json b/.eslintrc.json index af1b97849b..cb5775ef1f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,10 @@ "eslint-plugin-deprecation", "unused-imports", "eslint-plugin-lodash", - "eslint-plugin-jsonc" + "eslint-plugin-jsonc", + "eslint-plugin-rxjs", + "eslint-plugin-simple-import-sort", + "eslint-plugin-import-newlines" ], "overrides": [ { @@ -27,17 +30,29 @@ "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:@angular-eslint/recommended", - "plugin:@angular-eslint/template/process-inline-templates" + "plugin:@angular-eslint/template/process-inline-templates", + "plugin:rxjs/recommended" ], "rules": { + "indent": [ + "error", + 2, + { + "SwitchCase": 1 + } + ], "max-classes-per-file": [ "error", 1 ], "comma-dangle": [ - "off", + "error", "always-multiline" ], + "object-curly-spacing": [ + "error", + "always" + ], "eol-last": [ "error", "always" @@ -104,15 +119,13 @@ "allowTernary": true } ], - "prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) + "prefer-const": "error", + "no-case-declarations": "error", + "no-extra-boolean-cast": "error", "prefer-spread": "off", "no-underscore-dangle": "off", - - // todo: disabled rules from eslint:recommended, consider re-enabling & fixing "no-prototype-builtins": "off", "no-useless-escape": "off", - "no-case-declarations": "off", - "no-extra-boolean-cast": "off", "@angular-eslint/directive-selector": [ "error", @@ -183,7 +196,7 @@ ], "@typescript-eslint/type-annotation-spacing": "error", "@typescript-eslint/unified-signatures": "error", - "@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable + "@typescript-eslint/ban-types": "error", "@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/restrict-plus-operands": "warn", @@ -203,14 +216,45 @@ "deprecation/deprecation": "warn", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", "import/order": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", "import/no-deprecated": "warn", "import/no-namespace": "error", + "import-newlines/enforce": [ + "error", + { + "items": 1, + "semi": true, + "forceSingleLine": true + } + ], + "unused-imports/no-unused-imports": "error", "lodash/import-scope": [ "error", "method" - ] + ], + + "rxjs/no-nested-subscribe": "off" // todo: go over _all_ cases + } + }, + { + "files": [ + "*.spec.ts" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./cypress/tsconfig.json" + ], + "createDefaultProgram": true + }, + "rules": { + "prefer-const": "off" } }, { @@ -219,12 +263,7 @@ ], "extends": [ "plugin:@angular-eslint/template/recommended" - ], - "rules": { - // todo: re-enable & fix errors - "@angular-eslint/template/no-negated-async": "off", - "@angular-eslint/template/eqeqeq": "off" - } + ] }, { "files": [ 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..52f20470a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,8 @@ jobs: #CHROME_VERSION: "90.0.4430.212-1" # Bump Node heap size (OOM in CI after upgrading to Angular 15) NODE_OPTIONS: '--max-old-space-size=4096' + # Project name to use when running docker-compose prior to e2e tests + COMPOSE_PROJECT_NAME: 'ci' strategy: # Create a matrix of Node versions to test against (in parallel) matrix: @@ -43,11 +45,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 +120,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 +193,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 +205,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..36d6a009d3 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 @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -131,12 +131,16 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + # Always show the duplicate detection section if enabled, even if there are no potential duplicates detected + # (a message will be displayed to indicate no matches were found) + duplicateDetection: + alwaysShowSection: false icons: metadata: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +151,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -208,6 +234,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 +261,9 @@ languages: - code: el label: Ελληνικά active: true + - code: sr-cyr + label: Српски + active: true - code: uk label: Yкраї́нська active: true @@ -266,6 +298,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: @@ -279,8 +313,17 @@ item: # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 +# Community Page Config +community: + # Search tab config + searchSection: + showSidebar: true + # Collection Page Config collection: + # Search tab config + searchSection: + showSidebar: true edit: undoTimeout: 10000 # 10 seconds @@ -292,33 +335,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 @@ -376,7 +419,79 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' - sortDirection: 'ASC' \ No newline at end of file + sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + + +# Search settings +search: + # Settings to enable/disable or configure advanced search filters. + advancedFilters: + enabled: false + # List of filters to enable in "Advanced Search" dropdown + filter: [ 'title', 'author', 'subject', 'entityType' ] + + +# Notify metrics +# Configuration for Notify Admin Dashboard for metrics visualization +notifyMetrics: + # Configuration for received messages +- title: 'admin-notify-dashboard.received-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.incoming.accepted' + config: 'NOTIFY.incoming.accepted' + description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.incoming.processed' + config: 'NOTIFY.incoming.processed' + description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.failure' + config: 'NOTIFY.incoming.failure' + description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted' + config: 'NOTIFY.incoming.untrusted' + description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems' + textColor: '#fff' + config: 'NOTIFY.incoming.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description' +# Configuration for outgoing messages +- title: 'admin-notify-dashboard.generated-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued' + config: 'NOTIFY.outgoing.queued' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description' + - color: '#FDEEBB' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry' + config: 'NOTIFY.outgoing.queued_for_retry' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.outgoing.failure' + config: 'NOTIFY.outgoing.failure' + description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems' + textColor: '#fff' + config: 'NOTIFY.outgoing.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered' + config: 'NOTIFY.outgoing.delivered' + description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' + + + + + 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/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 07c20ad7c9..32c470231d 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Author', () => { cy.visit('/browse/author'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 4d22420227..7966f1c82e 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => { cy.visit('/browse/dateissued'); // Wait for to be visible - cy.get('ds-browse-by-date-page').should('be.visible'); + cy.get('ds-browse-by-date').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-date-page'); + testA11y('ds-browse-by-date'); }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 89b791f03c..57ca88d103 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Subject', () => { cy.visit('/browse/subject'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index e4e027586a..09195c30df 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Title', () => { cy.visit('/browse/title'); // Wait for to be visible - cy.get('ds-browse-by-title-page').should('be.visible'); + cy.get('ds-browse-by-title').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-title-page'); + testA11y('ds-browse-by-title'); }); }); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts new file mode 100644 index 0000000000..63d873db3e --- /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..a08f8cb198 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -1,12 +1,12 @@ -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.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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..6cafed0350 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -1,12 +1,12 @@ -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.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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..ece38686b9 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,18 +1,18 @@ -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'; describe('Site Statistics Page', () => { it('should load if you click on "Statistics" from homepage', () => { cy.visit('/'); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.location('pathname').should('eq', '/statistics'); }); it('should pass accessibility tests', () => { // generate 2 view events on an Item's page - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.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..6caeacae8e 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -1,12 +1,12 @@ -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.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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..673041e9f3 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,33 +1,33 @@ -import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; const page = { openLoginMenu() { // Click the "Log In" dropdown menu in header - cy.get('ds-themed-navbar [data-test="login-menu"]').click(); + cy.get('ds-themed-header [data-test="login-menu"]').click(); }, openUserMenu() { // Once logged in, click the User menu in header - cy.get('ds-themed-navbar [data-test="user-menu"]').click(); + cy.get('ds-themed-header [data-test="user-menu"]').click(); }, submitLoginAndPasswordByPressingButton(email, password) { // Enter email - cy.get('ds-themed-navbar [data-test="email"]').type(email); + cy.get('ds-themed-header [data-test="email"]').type(email); // Enter password - cy.get('ds-themed-navbar [data-test="password"]').type(password); + cy.get('ds-themed-header [data-test="password"]').type(password); // Click login button - cy.get('ds-themed-navbar [data-test="login-button"]').click(); + cy.get('ds-themed-header [data-test="login-button"]').click(); }, submitLoginAndPasswordByPressingEnter(email, password) { // In opened Login modal, fill out email & password, then click Enter - cy.get('ds-themed-navbar [data-test="email"]').type(email); - cy.get('ds-themed-navbar [data-test="password"]').type(password); - cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); + cy.get('ds-themed-header [data-test="email"]').type(email); + cy.get('ds-themed-header [data-test="password"]').type(password); + cy.get('ds-themed-header [data-test="password"]').type('{enter}'); }, submitLogoutByPressingButton() { // This is the POST command that will actually log us out cy.intercept('POST', '/server/api/authn/logout').as('logout'); // Click logout button - cy.get('ds-themed-navbar [data-test="logout-button"]').click(); + cy.get('ds-themed-header [data-test="logout-button"]').click(); // Wait until above POST command responds before continuing // (This ensures next action waits until logout completes) cy.wait('@logout'); @@ -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 @@ -102,12 +102,15 @@ describe('Login Modal', () => { page.openLoginMenu(); // Registration link should be visible - cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); + cy.get('ds-themed-header [data-test="register"]').should('be.visible'); // Click registration link & you should go to registration page - cy.get('ds-themed-navbar [data-test="register"]').click(); + cy.get('ds-themed-header [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', () => { @@ -116,11 +119,32 @@ describe('Login Modal', () => { page.openLoginMenu(); // Forgot password link should be visible - cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); + cy.get('ds-themed-header [data-test="forgot"]').should('be.visible'); // Click link & you should go to Forgot Password page - cy.get('ds-themed-navbar [data-test="forgot"]').click(); + cy.get('ds-themed-header [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..28a72bcc78 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,23 +1,21 @@ -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; - const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); + cy.get('ds-themed-header [data-test="header-search-icon"]').click(); // Fill out a query in input that appears - cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); + cy.get('ds-themed-header [data-test="header-search-box"]').type(query); }, submitQueryByPressingEnter() { - cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); + cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}'); }, submitQueryByPressingIcon() { - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); + cy.get('ds-themed-header [data-test="header-search-icon"]').click(); } }; 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..b2255a7da6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,45 +19,53 @@ 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.assetstore.yml b/docker/cli.assetstore.yml index 40e4974c7c..31bc53f64d 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -14,13 +14,8 @@ # Therefore, it should be kept in sync with that file version: "3.7" -networks: - dspacenet: - services: dspace-cli: - networks: - dspacenet: {} environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz diff --git a/docker/cli.yml b/docker/cli.yml index 54b83d4503..cc266b186f 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -13,10 +13,16 @@ # # Therefore, it should be kept in sync with that file version: "3.7" - +networks: + # Default to using network named 'dspacenet' from docker-compose-rest.yml. + # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") + # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) + default: + name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet + external: true services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-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. @@ -30,16 +36,12 @@ services: # solr.server: Ensure we are using the 'dspacesolr' image for Solr solr__P__server: http://dspacesolr:8983/solr volumes: - - "assetstore:/dspace/assetstore" + # Keep DSpace assetstore directory between reboots + - assetstore:/dspace/assetstore entrypoint: /dspace/bin/dspace command: help - networks: - - dspacenet tty: true stdin_open: true volumes: assetstore: - -networks: - dspacenet: diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 9ec8fe664a..07993e20c6 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -33,11 +33,11 @@ services: # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb - image: dspace/dspace:dspace-7_x-test networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -45,8 +45,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) @@ -70,21 +68,18 @@ services: PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: - dspacenet: + - dspacenet stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.11-slim - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -92,9 +87,6 @@ services: tty: true working_dir: /var/solr/data volumes: - # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) - # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume - - solr_configs:/opt/solr/server/solr/configsets/dspace # Keep Solr data directory between reboots - solr_data:/var/solr/data # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr @@ -103,14 +95,18 @@ services: - '-c' - | init-var-solr - precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - precreate-core search /opt/solr/server/solr/configsets/dspace/search - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: \ No newline at end of file diff --git a/docker/docker-compose-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..e1577ec837 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -39,11 +39,11 @@ 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: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -51,8 +51,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables @@ -69,25 +67,23 @@ services: container_name: dspacedb environment: PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto + image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 5432 target: 5432 stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}" - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -115,10 +111,10 @@ services: cp -r /opt/solr/server/solr/configsets/search/* search precreate-core statistics /opt/solr/server/solr/configsets/statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: diff --git a/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/karma.conf.js b/karma.conf.js index 8418312b1a..f96558bfaf 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,10 @@ module.exports = function (config) { ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser - captureConsole: false + captureConsole: false, + jasmine: { + failSpecWithNoExpectations: true + } }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/dspace-angular'), diff --git a/package.json b/package.json index 719b13b23b..c0a3843605 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,18 +117,18 @@ "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", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", - "sanitize-html": "^2.10.0", + "sanitize-html": "^2.12.1", "sortablejs": "1.15.0", "uuid": "^8.3.2", "webfontloader": "1.6.28", @@ -141,7 +142,7 @@ "@angular-eslint/eslint-plugin-template": "15.2.1", "@angular-eslint/schematics": "15.2.1", "@angular-eslint/template-parser": "15.2.1", - "@angular/cli": "^15.2.6", + "@angular/cli": "^16.0.4", "@angular/compiler-cli": "^15.2.8", "@angular/language-service": "^15.2.8", "@cypress/schematic": "^1.5.0", @@ -159,19 +160,22 @@ "@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", "eslint-plugin-deprecation": "^1.4.1", "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-import-newlines": "^1.3.1", + "eslint-plugin-jsdoc": "^45.0.0", "eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-lodash": "^7.4.0", + "eslint-plugin-rxjs": "^5.0.3", + "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-unused-imports": "^2.0.0", "express-static-gzip": "^2.1.7", "jasmine-core": "^3.8.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..06ae032194 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'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; -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..e85961fd13 100644 --- a/src/app/access-control/access-control-routing.module.ts +++ b/src/app/access-control/access-control-routing.module.ts @@ -1,69 +1,90 @@ import { NgModule } from '@angular/core'; 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 { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { GroupPageGuard } from './group-registry/group-page.guard'; +import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { - GroupAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { - SiteAdministratorGuard -} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; + EPERSON_PATH, + GROUP_PATH, +} from './access-control-routing-paths'; import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; +import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; +import { GroupFormComponent } from './group-registry/group-form/group-form.component'; +import { GroupPageGuard } from './group-registry/group-page.guard'; +import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; @NgModule({ imports: [ RouterModule.forChild([ { - path: 'epeople', + path: EPERSON_PATH, component: EPeopleRegistryComponent, resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, - canActivate: [SiteAdministratorGuard] + 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 + breadcrumb: I18nBreadcrumbResolver, }, data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, - canActivate: [GroupAdministratorGuard] + canActivate: [GroupAdministratorGuard], }, { - path: `${GROUP_EDIT_PATH}/newGroup`, + path: `${GROUP_PATH}/create`, component: GroupFormComponent, resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }, - canActivate: [GroupAdministratorGuard] + canActivate: [GroupAdministratorGuard], }, { - path: `${GROUP_EDIT_PATH}/:groupId`, + path: `${GROUP_PATH}/:groupId/edit`, component: GroupFormComponent, resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, - canActivate: [GroupPageGuard] + canActivate: [GroupPageGuard], }, { path: 'bulk-access', component: BulkAccessComponent, resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, - canActivate: [SiteAdministratorGuard] + canActivate: [SiteAdministratorGuard], }, - ]) - ] + ]), + ], }) /** * Routing module for the AccessControl section of the admin sidebar diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts index 3dc4b6cedc..87737987e0 100644 --- a/src/app/access-control/access-control.module.ts +++ b/src/app/access-control/access-control.module.ts @@ -1,23 +1,27 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; import { RouterModule } from '@angular/router'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { + DYNAMIC_ERROR_MESSAGES_MATCHER, + DynamicErrorMessagesMatcher, +} from '@ng-dynamic-forms/core'; + +import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module'; +import { FormModule } from '../shared/form/form.module'; +import { SearchModule } from '../shared/search/search.module'; import { SharedModule } from '../shared/shared.module'; import { AccessControlRoutingModule } from './access-control-routing.module'; +import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component'; import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { FormModule } from '../shared/form/form.module'; -import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; -import { AbstractControl } from '@angular/forms'; -import { BulkAccessComponent } from './bulk-access/bulk-access.component'; -import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; -import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component'; -import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component'; -import { SearchModule } from '../shared/search/search.module'; -import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module'; /** * Condition for displaying error messages on email form field @@ -55,9 +59,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = providers: [ { provide: DYNAMIC_ERROR_MESSAGES_MATCHER, - useValue: ValidateEmailErrorStateMatcher + useValue: ValidateEmailErrorStateMatcher, }, - ] + ], }) /** * This module handles all components related to the access control pages 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/browse/bulk-access-browse.component.spec.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts index 87b2a8d568..e99c25a195 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts @@ -1,16 +1,22 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; - -import { of } from 'rxjs'; -import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + NgbAccordionModule, + NgbNavModule, +} from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; -import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec'; -import { PageInfo } from '../../../core/shared/page-info.model'; -import { buildPaginatedList } from '../../../core/data/paginated-list.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; describe('BulkAccessBrowseComponent', () => { let component: BulkAccessBrowseComponent; @@ -31,13 +37,13 @@ describe('BulkAccessBrowseComponent', () => { imports: [ NgbAccordionModule, NgbNavModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), ], declarations: [BulkAccessBrowseComponent], - providers: [ { provide: SelectableListService, useValue: selectableListService }, ], + providers: [ { provide: SelectableListService, useValue: selectableListService } ], schemas: [ - NO_ERRORS_SCHEMA - ] + NO_ERRORS_SCHEMA, + ], }).compileComponents(); })); @@ -72,7 +78,7 @@ describe('BulkAccessBrowseComponent', () => { 'elementsPerPage': 5, 'totalElements': 2, 'totalPages': 1, - 'currentPage': 1 + 'currentPage': 1, }), [selected1, selected2]) ; const rd = createSuccessfulRemoteDataObject(list); diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts index e806e729c8..6b221f107e 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts @@ -1,19 +1,32 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; -import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; -import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; -import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { PageInfo } from '../../../core/shared/page-info.model'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; import { hasValue } from '../../../shared/empty.util'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; @Component({ selector: 'ds-bulk-access-browse', @@ -22,9 +35,9 @@ import { hasValue } from '../../../shared/empty.util'; providers: [ { provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] + useClass: SearchConfigurationService, + }, + ], }) export class BulkAccessBrowseComponent implements OnInit, OnDestroy { @@ -49,7 +62,7 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { paginationOptions$: BehaviorSubject = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { id: 'bas', pageSize: 5, - currentPage: 1 + currentPage: 1, })); /** @@ -67,20 +80,20 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } pageNext() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage + 1 + currentPage: this.paginationOptions$.value.currentPage + 1, })); } pagePrev() { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: this.paginationOptions$.value.currentPage - 1 + currentPage: this.paginationOptions$.value.currentPage - 1, })); } @@ -99,12 +112,12 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy { elementsPerPage: this.paginationOptions$.value.pageSize, totalElements: list?.selection.length, totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), - currentPage: this.paginationOptions$.value.currentPage + currentPage: this.paginationOptions$.value.currentPage, }); if (pageInfo.currentPage > pageInfo.totalPages) { pageInfo.currentPage = pageInfo.totalPages; this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { - currentPage: pageInfo.currentPage + currentPage: pageInfo.currentPage, })); } return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); 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/bulk-access.component.spec.ts b/src/app/access-control/bulk-access/bulk-access.component.spec.ts index e9b253147d..e7ec28c132 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.spec.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.spec.ts @@ -1,18 +1,20 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; - +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { BulkAccessComponent } from './bulk-access.component'; -import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { Process } from '../../process-page/processes/process.model'; -import { RouterTestingModule } from '@angular/router/testing'; +import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { BulkAccessComponent } from './bulk-access.component'; describe('BulkAccessComponent', () => { let component: BulkAccessComponent; @@ -31,35 +33,35 @@ describe('BulkAccessComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockFile = { 'uuids': [ - '1234', '5678' + '1234', '5678', ], - 'file': { } + 'file': { }, }; const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { getValue: jasmine.createSpy('getValue'), - reset: jasmine.createSpy('reset') + reset: jasmine.createSpy('reset'), }); const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; const selectableListState: SelectableListState = { id: 'test', selection }; @@ -71,15 +73,15 @@ describe('BulkAccessComponent', () => { await TestBed.configureTestingModule({ imports: [ RouterTestingModule, - TranslateModule.forRoot() + TranslateModule.forRoot(), ], declarations: [ BulkAccessComponent ], providers: [ { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, { provide: NotificationsService, useValue: NotificationsServiceStub }, - { provide: SelectableListService, useValue: selectableListServiceMock } + { provide: SelectableListService, useValue: selectableListServiceMock }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }) .compileComponents(); }); diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts index 04724614cb..52faa97dbc 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -1,17 +1,26 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import { + Component, + OnInit, + ViewChild, +} from '@angular/core'; +import { + BehaviorSubject, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + map, +} from 'rxjs/operators'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; - -import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; @Component({ selector: 'ds-bulk-access', templateUrl: './bulk-access.component.html', - styleUrls: ['./bulk-access.component.scss'] + styleUrls: ['./bulk-access.component.scss'], }) export class BulkAccessComponent implements OnInit { @@ -37,7 +46,7 @@ export class BulkAccessComponent implements OnInit { constructor( private bulkAccessControlService: BulkAccessControlService, - private selectableListService: SelectableListService + private selectableListService: SelectableListService, ) { } @@ -45,8 +54,8 @@ export class BulkAccessComponent implements OnInit { this.subs.push( this.selectableListService.getSelectableList(this.listId).pipe( distinctUntilChanged(), - map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) - ).subscribe(this.objectsSelected$) + map((list: SelectableListState) => this.generateIdListBySelectedElements(list)), + ).subscribe(this.objectsSelected$), ); } @@ -74,12 +83,12 @@ export class BulkAccessComponent implements OnInit { const { file } = this.bulkAccessControlService.createPayloadFile({ bitstreamAccess, itemAccess, - state: settings.state + state: settings.state, }); this.bulkAccessControlService.executeScript( this.objectsSelected$.value || [], - file + file, ).subscribe(); } 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/bulk-access/settings/bulk-access-settings.component.spec.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts index 14e0fdefb2..f3c1cad04a 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts @@ -1,8 +1,12 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; + import { BulkAccessSettingsComponent } from './bulk-access-settings.component'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; describe('BulkAccessSettingsComponent', () => { let component: BulkAccessSettingsComponent; @@ -15,35 +19,35 @@ describe('BulkAccessSettingsComponent', () => { 'startDate': { 'year': 2026, 'month': 5, - 'day': 31 + 'day': 31, }, - 'endDate': null - } + 'endDate': null, + }, ], 'state': { 'item': { 'toggleStatus': true, - 'accessMode': 'replace' + 'accessMode': 'replace', }, 'bitstream': { 'toggleStatus': false, 'accessMode': '', 'changesLimit': '', - 'selectedBitstreams': [] - } - } + 'selectedBitstreams': [], + }, + }, }; const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { getFormValue: jasmine.createSpy('getFormValue'), - reset: jasmine.createSpy('reset') + reset: jasmine.createSpy('reset'), }); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [NgbAccordionModule, TranslateModule.forRoot()], declarations: [BulkAccessSettingsComponent], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts index eecc016245..d0f95377eb 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts @@ -1,13 +1,15 @@ -import { Component, ViewChild } from '@angular/core'; import { - AccessControlFormContainerComponent -} from '../../../shared/access-control-form-container/access-control-form-container.component'; + Component, + ViewChild, +} from '@angular/core'; + +import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component'; @Component({ selector: 'ds-bulk-access-settings', templateUrl: 'bulk-access-settings.component.html', styleUrls: ['./bulk-access-settings.component.scss'], - exportAs: 'dsBulkSettings' + exportAs: 'dsBulkSettings', }) export class BulkAccessSettingsComponent { diff --git a/src/app/access-control/epeople-registry/epeople-registry.actions.ts b/src/app/access-control/epeople-registry/epeople-registry.actions.ts index a07ea37df2..e6e7608ba3 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.actions.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.actions.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; + import { EPerson } from '../../core/eperson/models/eperson.model'; import { type } from '../../shared/ngrx/type'; 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..92968d2e28 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..ee4d5fa603 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -1,47 +1,71 @@ -import { Router } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { + NgbModal, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; 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 { EPeopleRegistryComponent } from './epeople-registry.component'; -import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + EPersonMock, + EPersonMock2, +} from '../../shared/testing/eperson.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { RequestService } from '../../core/data/request.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { EPeopleRegistryComponent } from './epeople-registry.component'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; - let mockEPeople; + let mockEPeople: EPerson[]; let ePersonDataServiceStub: any; let authorizationService: AuthorizationDataService; - let modalService; + let modalService: NgbModal; + let paginationService: PaginationServiceStub; - let paginationService; - - beforeEach(waitForAsync(() => { + beforeEach(waitForAsync(async () => { jasmine.getEnv().allowRespy(true); mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { @@ -52,7 +76,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, getActiveEPerson(): Observable { @@ -67,7 +91,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } if (scope === 'metadata') { @@ -76,7 +100,7 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); } const result = this.allEpeople.find((ePerson: EPerson) => { @@ -86,20 +110,20 @@ describe('EPeopleRegistryComponent', () => { elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), [result])); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, - currentPage: 1 + currentPage: 1, }), this.allEpeople)); }, deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { return (ePerson2.uuid !== ePerson.uuid); - }); + }); return observableOf(true); }, editEPerson(ePerson: EPerson) { @@ -113,23 +137,17 @@ describe('EPeopleRegistryComponent', () => { }, getEPeoplePageRouterLink(): string { return '/access-control/epeople'; - } + }, }; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); builderService = getMockFormBuilderService(); - translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), + TranslateModule.forRoot(), ], declarations: [EPeopleRegistryComponent], providers: [ @@ -139,16 +157,16 @@ describe('EPeopleRegistryComponent', () => { { provide: FormBuilderService, useValue: builderService }, { provide: Router, useValue: new RouterStub() }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, - { provide: PaginationService, useValue: paginationService } + { provide: PaginationService, useValue: paginationService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; - modalService = (component as any).modalService; + modalService = TestBed.inject(NgbModal); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); @@ -158,10 +176,10 @@ describe('EPeopleRegistryComponent', () => { }); it('should display list of ePeople', () => { - const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); expect(ePeopleIdsFound.length).toEqual(2); mockEPeople.map((ePerson: EPerson) => { - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); })).toBeTruthy(); }); @@ -169,7 +187,7 @@ describe('EPeopleRegistryComponent', () => { describe('search', () => { describe('when searching with scope/query (scope metadata)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'metadata', query: EPersonMock2.name }); tick(); @@ -179,14 +197,14 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); })).toBeTruthy(); }); }); describe('when searching with scope/query (scope email)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'email', query: EPersonMock.email }); tick(); @@ -196,43 +214,13 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); })).toBeTruthy(); }); }); }); - 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; @@ -242,7 +230,7 @@ describe('EPeopleRegistryComponent', () => { const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton')); deleteButtons[0].triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); @@ -258,19 +246,12 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('delete EPerson button when the isAuthorized returns false', () => { - let ePeopleDeleteButton; - beforeEach(() => { - spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); - component.initialisePage(); - fixture.detectChanges(); - }); - it('should be disabled', () => { - ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); - ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { - expect(deleteButton.nativeElement.disabled).toBe(true); - }); - }); + it('should hide delete EPerson button when the isAuthorized returns false', () => { + spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); + component.initialisePage(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull(); }); }); 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..ddf5fe7bfb 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -1,27 +1,51 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; import { UntypedFormBuilder } from '@angular/forms'; import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { + BehaviorSubject, + combineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { + buildPaginatedList, + PaginatedList, +} from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; +import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; +import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, +} from '../../core/shared/operators'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../shared/empty.util'; 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 { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { getAllSucceededRemoteData, getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { RequestService } from '../../core/data/request.service'; -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', @@ -61,14 +85,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'elp', pageSize: 5, - currentPage: 1 + currentPage: 1, }); - /** - * Whether or not to show the EPerson form - */ - isEPersonFormShown: boolean; - // The search form searchForm; @@ -114,26 +133,20 @@ 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.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); 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(); epersonDtoModel.ableToDelete = authorized; epersonDtoModel.eperson = eperson; return epersonDtoModel; - }) + }), ); - })]).pipe(map((dtos: EpersonDtoModel[]) => { + })).pipe(map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epeople.pageInfo, dtos); })); } else { @@ -157,34 +170,34 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { } this.findListOptionsSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((findListOptions) => { - const query: string = data.query; - const scope: string = data.scope; - if (query != null && this.currentSearchQuery !== query) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { - queryParamsHandling: 'merge' - }); - this.currentSearchQuery = query; - this.paginationService.resetPage(this.config.id); - } - if (scope != null && this.currentSearchScope !== scope) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { - queryParamsHandling: 'merge' - }); - this.currentSearchScope = scope; - this.paginationService.resetPage(this.config.id); - - } - return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { - currentPage: findListOptions.currentPage, - elementsPerPage: findListOptions.pageSize + const query: string = data.query; + const scope: string = data.scope; + if (query != null && this.currentSearchQuery !== query) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', }); + this.currentSearchQuery = query; + this.paginationService.resetPage(this.config.id); } + if (scope != null && this.currentSearchScope !== scope) { + void this.router.navigate([getEPersonsRoute()], { + queryParamsHandling: 'merge', + }); + this.currentSearchScope = scope; + this.paginationService.resetPage(this.config.id); + + } + return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + currentPage: findListOptions.currentPage, + elementsPerPage: findListOptions.pageSize, + }); + }, ), getAllSucceededRemoteData(), ).subscribe((peopleRD) => { - this.ePeople$.next(peopleRD.payload); - this.pageInfoState$.next(peopleRD.payload.pageInfo); - } + this.ePeople$.next(peopleRD.payload); + this.pageInfoState$.next(peopleRD.payload.pageInfo); + }, ); } @@ -194,7 +207,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ isActive(eperson: EPerson): Observable { return this.getActiveEPerson().pipe( - map((activeEPerson) => eperson === activeEPerson) + map((activeEPerson) => eperson === activeEPerson), ); } @@ -205,30 +218,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'; @@ -240,9 +236,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { if (hasValue(ePerson.id)) { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { if (restResponse.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); + 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 +260,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 */ @@ -281,23 +267,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.searchForm.patchValue({ query: '', }); - this.search({query: ''}); + 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/epeople-registry.reducers.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts index 7158acc79b..6bee3f84e2 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.spec.ts @@ -1,6 +1,12 @@ -import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions'; -import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers'; import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction, +} from './epeople-registry.actions'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState, +} from './epeople-registry.reducers'; const initialState: EPeopleRegistryState = { editEPerson: null, diff --git a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts index 1e0319f3ba..3bab6769e1 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.reducers.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.reducers.ts @@ -2,7 +2,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPeopleRegistryAction, EPeopleRegistryActionTypes, - EPeopleRegistryEditEPersonAction + EPeopleRegistryEditEPersonAction, } from './epeople-registry.actions'; /** @@ -30,13 +30,13 @@ export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegi case EPeopleRegistryActionTypes.EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson + editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson, }); } case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { return Object.assign({}, state, { - editEPerson: null + editEPerson: null, }); } 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..9168bbaf8e 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..5196eee631 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 @@ -1,36 +1,70 @@ -import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../core/auth/auth.service'; +import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; 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 { PaginationService } from '../../../core/pagination/pagination.service'; import { PageInfo } from '../../../core/shared/page-info.model'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { AuthServiceStub } from '../../../shared/testing/auth-service.stub'; +import { + EPersonMock, + EPersonMock2, +} from '../../../shared/testing/eperson.mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { HasNoValuePipe } from '../../../shared/utils/has-no-value.pipe'; import { EPeopleRegistryComponent } from '../epeople-registry.component'; import { EPersonFormComponent } from './eperson-form.component'; -import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; -import { AuthService } from '../../../core/auth/auth.service'; -import { AuthServiceStub } from '../../../shared/testing/auth-service.stub'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { RequestService } from '../../../core/data/request.service'; -import { PaginationService } from '../../../core/pagination/pagination.service'; -import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; -import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -43,6 +77,8 @@ describe('EPersonFormComponent', () => { let authorizationService: AuthorizationDataService; let groupsDataService: GroupDataService; let epersonRegistrationService: EpersonRegistrationService; + let route: ActivatedRouteStub; + let router: RouterStub; let paginationService; @@ -106,70 +142,73 @@ 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(),{ createFormGroup(formModel, options = null) { const controls = {}; formModel.forEach( model => { - model.parent = parent; - const controlModel = model; - const controlState = { value: controlModel.value, disabled: controlModel.disabled }; - const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); - controls[model.id] = new UntypedFormControl(controlState, controlOptions); + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new UntypedFormControl(controlState, controlOptions); }); return new UntypedFormGroup(controls, options); }, createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { return { - validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, }; }, getValidators(validatorsConfig) { - return this.getValidatorFns(validatorsConfig); + return this.getValidatorFns(validatorsConfig); }, getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { let validatorFns = []; if (this.isObject(validatorsConfig)) { - validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { - const validatorConfigValue = validatorsConfig[validatorConfigKey]; - if (this.isValidatorDescriptor(validatorConfigValue)) { - const descriptor = validatorConfigValue; - return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); - } - return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); - }); + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); } return validatorFns; }, getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { let validatorFn; if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators - validatorFn = Validators[validatorName]; + validatorFn = Validators[validatorName]; } else { // Custom Validators - if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { - validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); - } else if (validatorsToken) { - validatorFn = validatorsToken.find(validator => validator.name === validatorName); - } + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } } if (validatorFn === undefined) { // throw when no validator could be resolved - throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); } if (validatorArgs !== null) { - return validatorFn(validatorArgs); + return validatorFn(validatorArgs); } return validatorFn; - }, + }, isValidatorDescriptor(value) { - if (this.isObject(value)) { - return value.hasOwnProperty('name') && value.hasOwnProperty('args'); - } - return false; + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; }, isObject(value) { return typeof value === 'object' && value !== null; - } + }, }); authService = new AuthServiceStub(); authorizationService = jasmine.createSpyObj('authorizationService', { @@ -178,20 +217,25 @@ describe('EPersonFormComponent', () => { }); groupsDataService = jasmine.createSpyObj('groupsDataService', { findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), - getGroupRegistryRouterLink: '' + getGroupRegistryRouterLink: '', }); paginationService = new PaginationServiceStub(); + route = new ActivatedRouteStub(); + router = new RouterStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], - declarations: [EPersonFormComponent], + declarations: [ + EPersonFormComponent, + HasNoValuePipe, + ], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: GroupDataService, useValue: groupsDataService }, @@ -200,16 +244,18 @@ describe('EPersonFormComponent', () => { { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: PaginationService, useValue: paginationService }, - { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}, + { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, - EPeopleRegistryComponent + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useValue: router }, + EPeopleRegistryComponent, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - registerEmail: createSuccessfulRemoteDataObject$(null) + registerEmail: createSuccessfulRemoteDataObject$(null), }); beforeEach(() => { @@ -241,12 +287,12 @@ describe('EPersonFormComponent', () => { metadata: { 'eperson.firstname': [ { - value: firstName - } + value: firstName, + }, ], 'eperson.lastname': [ { - value: lastName + value: lastName, }, ], }, @@ -263,24 +309,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 +330,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(() => { - 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(() => { - 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(() => { - expect(component.formGroup.controls.email.valid).toBeTrue(); - expect(component.formGroup.controls.email.errors).toBeNull(); - }); - })); + 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', () => { + expect(component.formGroup.controls.lastName.valid).toBeTrue(); + expect(component.formGroup.controls.lastName.errors).toBeNull(); + }); + 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 +350,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(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); - }); - })); + 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', () => { @@ -329,19 +361,17 @@ describe('EPersonFormComponent', () => { const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{ getEPersonByEmail(): Observable> { return createSuccessfulRemoteDataObject$(EPersonMock); - } + }, }); component.formGroup.controls.email.setValue('test@test.com'); component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson)); fixture.detectChanges(); }); - it('email should not be valid because email is already taken', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); - }); - })); + 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(); + }); }); @@ -366,12 +396,12 @@ describe('EPersonFormComponent', () => { metadata: { 'eperson.firstname': [ { - value: firstName - } + value: firstName, + }, ], 'eperson.lastname': [ { - value: lastName + value: lastName, }, ], }, @@ -393,11 +423,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', () => { @@ -409,30 +437,28 @@ describe('EPersonFormComponent', () => { metadata: { 'eperson.firstname': [ { - value: firstName - } + value: firstName, + }, ], 'eperson.lastname': [ { - value: lastName + value: lastName, }, ], }, email: email, canLogIn: canLogIn, requireCertificate: requireCertificate, - _links: undefined + _links: undefined, }); spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); component.onSubmit(); 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); + }); }); }); @@ -443,7 +469,7 @@ describe('EPersonFormComponent', () => { spyOn(authService, 'impersonate').and.callThrough(); ePersonId = 'testEPersonId'; component.epersonInitial = Object.assign(new EPerson(), { - id: ePersonId + id: ePersonId, }); component.impersonate(); }); @@ -491,16 +517,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', () => { @@ -531,7 +557,7 @@ describe('EPersonFormComponent', () => { ePersonEmail = 'person.email@4science.it'; component.epersonInitial = Object.assign(new EPerson(), { id: ePersonId, - email: ePersonEmail + email: ePersonEmail, }); component.resetPassword(); }); 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..7eb743f1ab 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 @@ -1,43 +1,68 @@ -import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicCheckboxModel, DynamicFormControlModel, DynamicFormLayout, - DynamicInputModel + DynamicInputModel, } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { debounceTime, finalize, map, switchMap, take } from 'rxjs/operators'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; +import { + debounceTime, + finalize, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { AuthService } from '../../../core/auth/auth.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; 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 { PaginationService } from '../../../core/pagination/pagination.service'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getRemoteDataPayload + getRemoteDataPayload, } from '../../../core/shared/operators'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Registration } from '../../../core/shared/registration.model'; +import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; +import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { AuthService } from '../../../core/auth/auth.service'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; -import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { RequestService } from '../../../core/data/request.service'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { PaginationService } from '../../../core/pagination/pagination.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { getEPersonsRoute } from '../../access-control-routing-paths'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; -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'; @Component({ selector: 'ds-eperson-form', @@ -81,28 +106,28 @@ export class EPersonFormComponent implements OnInit, OnDestroy { formLayout: DynamicFormLayout = { firstName: { grid: { - host: 'row' - } + host: 'row', + }, }, lastName: { grid: { - host: 'row' - } + host: 'row', + }, }, email: { grid: { - host: 'row' - } + host: 'row', + }, }, canLogIn: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, requireCertificate: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, }; @@ -145,7 +170,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { /** * A list of all the groups this EPerson is a member of */ - groups: Observable>>; + groups$: Observable>>; + + /** + * The pagination of the {@link groups$} list. + */ + groupsPageInfoState$: Observable; /** * Pagination config used to display the list of groups @@ -153,7 +183,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'gem', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** @@ -194,6 +224,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 +245,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`), @@ -251,23 +285,23 @@ export class EPersonFormComponent implements OnInit, OnDestroy { required: true, errorMessages: { emailTaken: 'error.validation.emailTaken', - pattern: 'error.validation.NotValidEmail' + pattern: 'error.validation.NotValidEmail', }, - hint: emailHint + hint: emailHint, }); this.canLogIn = new DynamicCheckboxModel( { id: 'canLogIn', label: canLogIn, name: 'canLogIn', - value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true) + value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true), }); this.requireCertificate = new DynamicCheckboxModel( { id: 'requireCertificate', label: requireCertificate, name: 'requireCertificate', - value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false) + value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false), }); this.formModel = [ this.firstName, @@ -279,9 +313,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { if (eperson != null) { - this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { + this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, { currentPage: 1, - elementsPerPage: this.config.pageSize + elementsPerPage: this.config.pageSize, }); } this.formGroup.patchValue({ @@ -289,7 +323,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', email: eperson != null ? eperson.email : '', canLogIn: eperson != null ? eperson.canLogIn : true, - requireCertificate: eperson != null ? eperson.requireCertificate : false + requireCertificate: eperson != null ? eperson.requireCertificate : false, }); if (eperson === null && !!this.formGroup.controls.email) { @@ -302,11 +336,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { const activeEPerson$ = this.epersonService.getActiveEPerson(); - this.groups = activeEPerson$.pipe( + this.groups$ = activeEPerson$.pipe( switchMap((eperson) => { return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { currentPage: 1, - elementsPerPage: this.config.pageSize + elementsPerPage: this.config.pageSize, })]); }), switchMap(([eperson, findListOptions]) => { @@ -314,7 +348,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } return observableOf(undefined); - }) + }), + ); + + this.groupsPageInfoState$ = this.groups$.pipe( + map(groupsRD => groupsRD.payload.pageInfo), ); this.canImpersonate$ = activeEPerson$.pipe( @@ -324,10 +362,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } else { return observableOf(false); } - }) + }), ); this.canDelete$ = activeEPerson$.pipe( - switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) + switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)), ); this.canReset$ = observableOf(true); }); @@ -339,6 +377,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onCancel() { this.epersonService.cancelEditEPerson(); this.cancelForm.emit(); + void this.router.navigate([getEPersonsRoute()]); } /** @@ -354,12 +393,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { metadata: { 'eperson.firstname': [ { - value: this.firstName.value - } + value: this.firstName.value, + }, ], 'eperson.lastname': [ { - value: this.lastName.value + value: this.lastName.value, }, ], }, @@ -372,7 +411,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } else { this.editEPerson(ePerson, values); } - } + }, ); } @@ -385,11 +424,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { const response = this.epersonService.create(ePersonToCreate); response.pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { 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(); @@ -409,12 +450,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { metadata: { 'eperson.firstname': [ { - value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')) - } + value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')), + }, ], 'eperson.lastname': [ { - value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')) + value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')), }, ], }, @@ -429,6 +470,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(); @@ -447,7 +489,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onPageChange(event) { this.updateGroups({ currentPage: event, - elementsPerPage: this.config.pageSize + elementsPerPage: this.config.pageSize, }); } @@ -468,7 +510,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'; @@ -483,18 +525,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.canDelete$ = observableOf(false); return this.epersonService.deleteEPerson(eperson).pipe( getFirstCompletedRemoteData(), - map((restResponse: RemoteData) => ({ restResponse, eperson })) + map((restResponse: RemoteData) => ({ restResponse, eperson })), ); } else { return observableOf(null); } }), - finalize(() => this.canDelete$ = observableOf(true)) + finalize(() => this.canDelete$ = observableOf(true)), ); - }) + }), ).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}`); } @@ -518,14 +561,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy { if (hasValue(this.epersonInitial.email)) { this.epersonRegistrationService.registerEmail(this.epersonInitial.email, null, TYPE_REQUEST_FORGOT).pipe(getFirstCompletedRemoteData()) .subscribe((response: RemoteData) => { - if (response.hasSucceeded) { - this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), - this.translateService.get('forgot-email.form.success.content', {email: this.epersonInitial.email})); - } else { - this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), - this.translateService.get('forgot-email.form.error.content', {email: this.epersonInitial.email})); - } + if (response.hasSucceeded) { + this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), + this.translateService.get('forgot-email.form.success.content', { email: this.epersonInitial.email })); + } else { + this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), + this.translateService.get('forgot-email.form.error.content', { email: this.epersonInitial.email })); } + }, ); } } @@ -541,16 +584,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 @@ -561,13 +594,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { // Relevant message for email in use this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { currentPage: 1, - elementsPerPage: 0 + elementsPerPage: 0, }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) .subscribe((list: PaginatedList) => { if (list.totalElements > 0) { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { name: this.dsoNameService.getName(ePerson), - email: ePerson.email + email: ePerson.email, })); } })); @@ -578,7 +611,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ private updateGroups(options) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); + this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options); })); } } diff --git a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts index 5153abae7c..2a689c0d72 100644 --- a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts +++ b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts @@ -1,9 +1,12 @@ -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; -import { getFirstSucceededRemoteData, } from '../../../../core/shared/operators'; +import { getFirstSucceededRemoteData } from '../../../../core/shared/operators'; export class ValidateEmailNotTaken { @@ -17,8 +20,8 @@ export class ValidateEmailNotTaken { .pipe( getFirstSucceededRemoteData(), map(res => { - return !!res.payload ? { emailTaken: true } : null; - }) + return res.payload ? { emailTaken: true } : null; + }), ); }; } 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..33233ba7e8 --- /dev/null +++ b/src/app/access-control/epeople-registry/eperson-resolver.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { RemoteData } from '../../core/data/remote-data'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +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..dc477352e5 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}}

- -

+ +

- @@ -36,22 +36,21 @@ [displayCancel]="false" (submitForm)="onSubmit()">
-
-
-
-
- diff --git a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index f8c5f3cd87..aca6da2a74 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -1,43 +1,73 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf } from 'rxjs'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { DSOChangeAnalyzer } from '../../../core/data/dso-change-analyzer.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + 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 { Group } from '../../../core/eperson/models/group.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { PageInfo } from '../../../core/shared/page-info.model'; import { UUIDService } from '../../../core/shared/uuid.service'; 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 { GroupFormComponent } from './group-form.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -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 { RouterMock } from '../../../shared/mocks/router.mock'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { Operation } from 'fast-json-patch'; -import { ValidateGroupExists } from './validators/group-exists.validator'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../shared/mocks/router.mock'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { + GroupMock, + GroupMock2, +} from '../../../shared/testing/group-mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock'; +import { GroupFormComponent } from './group-form.component'; +import { ValidateGroupExists } from './validators/group-exists.validator'; describe('GroupFormComponent', () => { let component: GroupFormComponent; @@ -65,8 +95,8 @@ describe('GroupFormComponent', () => { metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, }); @@ -105,7 +135,7 @@ describe('GroupFormComponent', () => { create(group: Group): Observable> { this.allGroups = [...this.allGroups, group]; this.createdGroup = Object.assign({}, group, { - _links: { self: { href: 'group-selflink' } } + _links: { self: { href: 'group-selflink' } }, }); return createSuccessfulRemoteDataObject$(this.createdGroup); }, @@ -114,78 +144,78 @@ describe('GroupFormComponent', () => { }, getGroupEditPageRouterLinkWithID(id: string) { return `group-edit-page-for-${id}`; - } + }, }; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); dsoDataServiceStub = { findByHref(href: string): Observable> { return null; - } + }, }; builderService = Object.assign(getMockFormBuilderService(),{ createFormGroup(formModel, options = null) { const controls = {}; formModel.forEach( model => { - model.parent = parent; - const controlModel = model; - const controlState = { value: controlModel.value, disabled: controlModel.disabled }; - const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); - controls[model.id] = new UntypedFormControl(controlState, controlOptions); + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new UntypedFormControl(controlState, controlOptions); }); return new UntypedFormGroup(controls, options); }, createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { return { - validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, }; }, getValidators(validatorsConfig) { - return this.getValidatorFns(validatorsConfig); + return this.getValidatorFns(validatorsConfig); }, getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { let validatorFns = []; if (this.isObject(validatorsConfig)) { - validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { - const validatorConfigValue = validatorsConfig[validatorConfigKey]; - if (this.isValidatorDescriptor(validatorConfigValue)) { - const descriptor = validatorConfigValue; - return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); - } - return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); - }); + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); } return validatorFns; }, getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { let validatorFn; if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators - validatorFn = Validators[validatorName]; + validatorFn = Validators[validatorName]; } else { // Custom Validators - if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { - validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); - } else if (validatorsToken) { - validatorFn = validatorsToken.find(validator => validator.name === validatorName); - } + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } } if (validatorFn === undefined) { // throw when no validator could be resolved - throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); } if (validatorArgs !== null) { - return validatorFn(validatorArgs); + return validatorFn(validatorArgs); } return validatorFn; - }, + }, isValidatorDescriptor(value) { - if (this.isObject(value)) { - return value.hasOwnProperty('name') && value.hasOwnProperty('args'); - } - return false; + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; }, isObject(value) { return typeof value === 'object' && value !== null; - } + }, }); translateService = getMockTranslateService(); router = new RouterMock(); @@ -195,8 +225,8 @@ describe('GroupFormComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], declarations: [GroupFormComponent], @@ -216,12 +246,12 @@ describe('GroupFormComponent', () => { { provide: HALEndpointService, useValue: {} }, { provide: ActivatedRoute, - useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } + useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) }, }, { provide: Router, useValue: router }, { provide: AuthorizationDataService, useValue: authorizationService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -257,8 +287,8 @@ describe('GroupFormComponent', () => { metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, }); @@ -273,11 +303,11 @@ describe('GroupFormComponent', () => { const operations = [{ op: 'add', path: '/metadata/dc.description', - value: 'testDescription' + value: 'testDescription', }, { op: 'replace', path: '/name', - value: 'newGroupName' + value: 'newGroupName', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); @@ -289,7 +319,7 @@ describe('GroupFormComponent', () => { const operations = [{ op: 'add', path: '/metadata/dc.description', - value: 'testDescription' + value: 'testDescription', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); @@ -301,7 +331,7 @@ describe('GroupFormComponent', () => { const operations = [{ op: 'replace', path: '/name', - value: 'newGroupName' + value: 'newGroupName', }]; expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); @@ -338,8 +368,8 @@ describe('GroupFormComponent', () => { metadata: { 'dc.description': [ { - value: groupDescription - } + value: groupDescription, + }, ], }, }); @@ -376,7 +406,7 @@ describe('GroupFormComponent', () => { const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{ searchGroups(query: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected])); - } + }, }); component.formGroup.controls.groupName.setValue('testName'); component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup)); @@ -400,7 +430,7 @@ describe('GroupFormComponent', () => { component.canEdit$ = observableOf(true); component.groupBeingEdited = { - permanent: false + permanent: false, } as Group; fixture.detectChanges(); 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..89bb4c395f 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 @@ -1,24 +1,45 @@ -import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DynamicFormControlModel, DynamicFormLayout, DynamicInputModel, - DynamicTextAreaModel + DynamicTextAreaModel, } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; import { - ObservedValueOf, combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription, } from 'rxjs'; -import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators'; +import { + catchError, + debounceTime, + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../../environments/environment'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; @@ -31,27 +52,32 @@ import { Group } from '../../../core/eperson/models/group.model'; import { Collection } from '../../../core/shared/collection.model'; import { Community } from '../../../core/shared/community.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { NoContent } from '../../../core/shared/NoContent.model'; import { - getRemoteDataPayload, - getFirstSucceededRemoteData, getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, } 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 { + hasValue, + hasValueOperator, + isNotEmpty, +} from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { NoContent } from '../../../core/shared/NoContent.model'; -import { Operation } from 'fast-json-patch'; +import { + getGroupEditRoute, + getGroupsRoute, +} from '../../access-control-routing-paths'; import { ValidateGroupExists } from './validators/group-exists.validator'; -import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { environment } from '../../../../environments/environment'; @Component({ selector: 'ds-group-form', - templateUrl: './group-form.component.html' + templateUrl: './group-form.component.html', }) /** * A form used for creating and editing groups @@ -83,13 +109,13 @@ export class GroupFormComponent implements OnInit, OnDestroy { formLayout: DynamicFormLayout = { groupName: { grid: { - host: 'row' - } + host: 'row', + }, }, groupDescription: { grid: { - host: 'row' - } + host: 'row', + }, }, }; @@ -165,19 +191,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]) => { + this.translateService.get(`${this.messagePrefix}.groupDescription`), + ]).subscribe(([groupName, groupCommunity, groupDescription]) => { this.groupName = new DynamicInputModel({ id: 'groupName', label: groupName, @@ -207,7 +233,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { ]; this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - if (!!this.formGroup.controls.groupName) { + if (this.formGroup.controls.groupName) { this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { this.changeDetectorRef.detectChanges(); @@ -215,12 +241,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]) => { + .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))), + ]).subscribe(([activeGroup, canEdit, linkedObject]) => { if (activeGroup != null) { @@ -230,12 +256,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, @@ -252,7 +280,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { } }, 200); } - }) + }), ); }); } @@ -263,7 +291,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()]); } /** @@ -280,9 +308,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { metadata: { 'dc.description': [ { - value: this.groupDescription.value - } - ] + value: this.groupDescription.value, + }, + ], }, }; if (group === null) { @@ -290,7 +318,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { } else { this.editGroup(group); } - } + }, ); } @@ -301,7 +329,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { createNewGroup(values) { const groupToCreate = Object.assign(new Group(), values); this.groupDataService.create(groupToCreate).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.created.success', { name: groupToCreate.name })); @@ -310,7 +338,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 })); @@ -330,7 +358,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { // Relevant message for group name in use this.subs.push(this.groupDataService.searchGroups(group.name, { currentPage: 1, - elementsPerPage: 0 + elementsPerPage: 0, }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) .subscribe((list: PaginatedList) => { if (list.totalElements > 0) { @@ -352,7 +380,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { operations = [...operations, { op: 'add', path: '/metadata/dc.description', - value: this.groupDescription.value + value: this.groupDescription.value, }]; } @@ -360,12 +388,12 @@ export class GroupFormComponent implements OnInit, OnDestroy { operations = [...operations, { op: 'replace', path: '/name', - value: this.groupName.value + value: this.groupName.value, }]; } this.groupDataService.patch(group, operations).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: this.dsoNameService.getName(rd.payload) })); @@ -418,7 +446,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'; @@ -504,7 +532,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { return getCollectionEditRolesRoute(rd.payload.id); } } - }) + }), ); } } 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..85aa987605 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 : '-' }}
- -
+ +
+ + + + + +
+
+
+ + + + +
+
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + + { describe('canActivate', () => { @@ -20,7 +24,7 @@ describe('CreateCollectionPageGuard', () => { } else if (id === 'error-id') { return createFailedRemoteDataObject$('not found', 404); } - } + }, }; router = new RouterMock(); @@ -32,7 +36,7 @@ describe('CreateCollectionPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); @@ -41,7 +45,7 @@ describe('CreateCollectionPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); @@ -50,7 +54,7 @@ describe('CreateCollectionPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); @@ -59,7 +63,7 @@ describe('CreateCollectionPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); }); diff --git a/src/app/collection-page/create-collection-page/create-collection-page.guard.ts b/src/app/collection-page/create-collection-page/create-collection-page.guard.ts index ca84231912..3e26f51b11 100644 --- a/src/app/collection-page/create-collection-page/create-collection-page.guard.ts +++ b/src/app/collection-page/create-collection-page/create-collection-page.guard.ts @@ -1,13 +1,27 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { map, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; /** * Prevent creation of a collection without a parent community provided @@ -37,7 +51,7 @@ export class CreateCollectionPageGuard implements CanActivate { if (!isValid) { this.router.navigate(['/404']); } - }) - ); + }), + ); } } diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html index ba54bbabd5..53c3b45cfa 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html @@ -2,7 +2,7 @@
- +

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

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

@@ -11,7 +11,7 @@
diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts index 9fc88932d0..986faafedb 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.spec.ts @@ -1,17 +1,22 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { DeleteCollectionPageComponent } from './delete-collection-page.component'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SharedModule } from '../../shared/shared.module'; +import { DeleteCollectionPageComponent } from './delete-collection-page.component'; describe('DeleteCollectionPageComponent', () => { let comp: DeleteCollectionPageComponent; @@ -26,9 +31,9 @@ describe('DeleteCollectionPageComponent', () => { { provide: CollectionDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, - { provide: RequestService, useValue: {} } + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts index 0a1b87e58b..87b21d3432 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts @@ -1,11 +1,15 @@ import { Component } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; -import { TranslateService } from '@ngx-translate/core'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; /** * Component that represents the page where a user can delete an existing Collection @@ -13,7 +17,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-delete-collection', styleUrls: ['./delete-collection-page.component.scss'], - templateUrl: './delete-collection-page.component.html' + templateUrl: './delete-collection-page.component.html', }) export class DeleteCollectionPageComponent extends DeleteComColPageComponent { protected frontendURL = '/collections/'; diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts index 04da8bbcd9..de28e4a23c 100644 --- a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts @@ -1,25 +1,69 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + of as observableOf, + of, +} from 'rxjs'; +import { Community } from '../../../core/shared/community.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CollectionAccessControlComponent } from './collection-access-control.component'; -xdescribe('CollectionAccessControlComponent', () => { +describe('CollectionAccessControlComponent', () => { let component: CollectionAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }], + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID', + }, + }, + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity), + }), + }, + }, + }; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CollectionAccessControlComponent ] + declarations: [ CollectionAccessControlComponent ], + providers: [{ provide: ActivatedRoute, useValue: routeStub }], }) - .compileComponents(); + .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(CollectionAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts index 4192fe5a9a..490a8d13ce 100644 --- a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts @@ -1,9 +1,13 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs/operators'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; @Component({ @@ -18,7 +22,7 @@ export class CollectionAccessControlComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.parent.parent.data.pipe( - map((data) => data.dso) + map((data) => data.dso), ).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts index c8e529443a..95d8b0ba04 100644 --- a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.spec.ts @@ -1,15 +1,21 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; - import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { CollectionAuthorizationsComponent } from './collection-authorizations.component'; import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CollectionAuthorizationsComponent } from './collection-authorizations.component'; describe('CollectionAuthorizationsComponent', () => { let comp: CollectionAuthorizationsComponent; @@ -19,8 +25,8 @@ describe('CollectionAuthorizationsComponent', () => { uuid: 'collection', id: 'collection', _links: { - self: { href: 'collection-selflink' } - } + self: { href: 'collection-selflink' }, + }, }); const collectionRD = createSuccessfulRemoteDataObject(collection); @@ -29,16 +35,16 @@ describe('CollectionAuthorizationsComponent', () => { parent: { parent: { data: observableOf({ - dso: collectionRD - }) - } - } + dso: collectionRD, + }), + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - CommonModule + CommonModule, ], declarations: [CollectionAuthorizationsComponent], providers: [ diff --git a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts index d1b59a0c90..ad8ccbd690 100644 --- a/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-authorizations/collection-authorizations.component.ts @@ -1,8 +1,13 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @@ -27,7 +32,7 @@ export class CollectionAuthorizationsComponent imp * @param {ActivatedRoute} route */ constructor( - private route: ActivatedRoute + private route: ActivatedRoute, ) { } diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html index 38c9d22f4e..4fbe60d6b1 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.html @@ -1,5 +1,5 @@
-

{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}

+

{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}

diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts index 2cf25734e1..1925478e3b 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.spec.ts @@ -1,12 +1,20 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { CollectionCurateComponent } from './collection-curate.component'; -import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { Collection } from '../../../core/shared/collection.model'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CollectionCurateComponent } from './collection-curate.component'; describe('CollectionCurateComponent', () => { let comp: CollectionCurateComponent; @@ -17,30 +25,30 @@ describe('CollectionCurateComponent', () => { let dsoNameService; const collection = Object.assign(new Collection(), { - metadata: {'dc.title': ['Collection Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} + metadata: { 'dc.title': ['Collection Name'], 'dc.identifier.uri': [ { value: '123456789/1' }] }, }); beforeEach(waitForAsync(() => { routeStub = { parent: { data: observableOf({ - dso: createSuccessfulRemoteDataObject(collection) - }) - } + dso: createSuccessfulRemoteDataObject(collection), + }), + }, }; dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: 'Collection Name' + getName: 'Collection Name', }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [CollectionCurateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: DSONameService, useValue: dsoNameService} + { provide: ActivatedRoute, useValue: routeStub }, + { provide: DSONameService, useValue: dsoNameService }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); })); @@ -58,7 +66,7 @@ describe('CollectionCurateComponent', () => { }); it('should contain the collection information provided in the route', () => { comp.dsoRD$.subscribe((value) => { - expect(value.payload.handle + expect(value.payload.handle, ).toEqual('123456789/1'); }); comp.collectionName$.subscribe((value) => { diff --git a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts index e20f229cd6..ea2f0265fa 100644 --- a/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-curate/collection-curate.component.ts @@ -1,9 +1,14 @@ import { Component } from '@angular/core'; -import { filter, map, take } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Observable } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; import { hasValue } from '../../../shared/empty.util'; @@ -34,7 +39,7 @@ export class CollectionCurateComponent { filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { return this.dsoNameService.getName(rd.payload); - }) + }), ); } } diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html index ffd8f71343..1cf40159ec 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html @@ -1,17 +1,17 @@
- + {{ 'collection.edit.template.label' | translate}}
diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts index 7cc54bd994..3e87f05148 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts @@ -1,20 +1,33 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared/shared.module'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { of as observableOf } from 'rxjs'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CollectionMetadataComponent } from './collection-metadata.component'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { Item } from '../../../core/shared/item.model'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + NavigationEnd, + Router, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { CollectionDataService } from '../../../core/data/collection-data.service'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; -import { Collection } from '../../../core/shared/collection.model'; import { RequestService } from '../../../core/data/request.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { Item } from '../../../core/shared/item.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { SharedModule } from '../../../shared/shared.module'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { CollectionMetadataComponent } from './collection-metadata.component'; describe('CollectionMetadataComponent', () => { let comp: CollectionMetadataComponent; @@ -24,16 +37,16 @@ describe('CollectionMetadataComponent', () => { const template = Object.assign(new Item(), { _links: { - self: { href: 'template-selflink' } - } + self: { href: 'template-selflink' }, + }, }); const collection = Object.assign(new Collection(), { uuid: 'collection-id', id: 'collection-id', name: 'Fake Collection', _links: { - self: { href: 'collection-selflink' } - } + self: { href: 'collection-selflink' }, + }, }); const collectionTemplateHref = 'rest/api/test/collections/template'; @@ -46,10 +59,10 @@ describe('CollectionMetadataComponent', () => { const notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, - error: {} + error: {}, }); const requestService = jasmine.createSpyObj('requestService', { - setStaleByHrefSubstring: {} + setStaleByHrefSubstring: {}, }); const routerMock = { @@ -67,9 +80,9 @@ describe('CollectionMetadataComponent', () => { { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: NotificationsService, useValue: notificationsService }, { provide: RequestService, useValue: requestService }, - { provide: Router, useValue: routerMock} + { provide: Router, useValue: routerMock }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index 634363527f..d71732840a 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -1,20 +1,39 @@ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; -import { Collection } from '../../../core/shared/collection.model'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { ActivatedRoute, NavigationEnd, Router, Scroll } from '@angular/router'; -import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; -import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Item } from '../../../core/shared/item.model'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + ChangeDetectorRef, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + NavigationEnd, + Router, + Scroll, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { + combineLatest as combineLatestObservable, + Observable, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; import { RequestService } from '../../../core/data/request.service'; -import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { Collection } from '../../../core/shared/collection.model'; +import { Item } from '../../../core/shared/item.model'; import { NoContent } from '../../../core/shared/NoContent.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../../core/shared/operators'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; /** * Component for editing a collection's metadata @@ -40,7 +59,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.findByCollectionID(collection.uuid)) + switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)), ); } diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index c375a23ddf..21a252b4cf 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -1,22 +1,32 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { ActivatedRoute } from '@angular/router'; -import { of as observableOf } from 'rxjs'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { CollectionRolesComponent } from './collection-roles.component'; -import { Collection } from '../../../core/shared/collection.model'; -import { SharedModule } from '../../../shared/shared.module'; -import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { RequestService } from '../../../core/data/request.service'; -import { RouterTestingModule } from '@angular/router/testing'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ComcolModule } from '../../../shared/comcol/comcol.module'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RequestService } from '../../../core/data/request.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { ComcolModule } from '../../../shared/comcol/comcol.module'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { SharedModule } from '../../../shared/shared.module'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { CollectionRolesComponent } from './collection-roles.component'; describe('CollectionRolesComponent', () => { @@ -54,10 +64,10 @@ describe('CollectionRolesComponent', () => { }, ], }, - }) + }), ), - }) - } + }), + }, }; const requestService = { @@ -74,7 +84,7 @@ describe('CollectionRolesComponent', () => { SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), - NoopAnimationsModule + NoopAnimationsModule, ], declarations: [ CollectionRolesComponent, @@ -84,9 +94,9 @@ describe('CollectionRolesComponent', () => { { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, - { provide: NotificationsService, useClass: NotificationsServiceStub } + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CollectionRolesComponent); diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts index 0177cc3a38..586a8fa8a6 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.ts @@ -1,11 +1,21 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { HALLink } from '../../../core/shared/hal-link.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; import { hasValue } from '../../../shared/empty.util'; /** 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..7edaadb0a1 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}} @@ -18,7 +18,7 @@ {{contentSource?.message ? contentSource?.message: 'collection.source.controls.harvest.no-information'|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 }}

-
diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts index e7e98d9523..fb00b3243b 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -1,25 +1,48 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { of as observableOf } from 'rxjs'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CollectionSourceComponent } from './collection-source.component'; -import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + DynamicFormControlModel, + DynamicFormService, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { RequestService } from '../../../core/data/request.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { + ContentSource, + ContentSourceHarvestType, +} from '../../../core/shared/content-source.model'; +import { hasValue } from '../../../shared/empty.util'; +import { + INotification, + Notification, +} from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; -import { hasValue } from '../../../shared/empty.util'; -import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; import { RouterStub } from '../../../shared/testing/router.stub'; -import { By } from '@angular/platform-browser'; -import { Collection } from '../../../core/shared/collection.model'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { RequestService } from '../../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { CollectionSourceComponent } from './collection-source.component'; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -50,29 +73,29 @@ describe('CollectionSourceComponent', () => { { id: 'dc', label: 'Simple Dublin Core', - nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/', }, { id: 'qdc', label: 'Qualified Dublin Core', - nameSpace: 'http://purl.org/dc/terms/' + nameSpace: 'http://purl.org/dc/terms/', }, { id: 'dim', label: 'DSpace Intermediate Metadata', - nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' - } + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim', + }, ], - _links: { self: { href: 'contentsource-selflink' } } + _links: { self: { href: 'contentsource-selflink' } }, }); fieldUpdate = { field: contentSource, - changeType: undefined + changeType: undefined, }; objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { getFieldUpdates: observableOf({ - [contentSource.uuid]: fieldUpdate + [contentSource.uuid]: fieldUpdate, }), saveAddFieldUpdate: {}, discardFieldUpdates: {}, @@ -82,15 +105,15 @@ describe('CollectionSourceComponent', () => { getLastModified: observableOf(date), hasUpdates: observableOf(true), isReinstatable: observableOf(false), - isValidPage: observableOf(true) - } + isValidPage: observableOf(true), + }, ); notificationsService = jasmine.createSpyObj('notificationsService', { info: infoNotification, warning: warningNotification, - success: successNotification - } + success: successNotification, + }, ); location = jasmine.createSpyObj('location', ['back']); formService = Object.assign({ @@ -103,18 +126,18 @@ describe('CollectionSourceComponent', () => { return new UntypedFormGroup(controls); } return undefined; - } + }, }); router = Object.assign(new RouterStub(), { - url: 'http://test-url.com/test-url' + url: 'http://test-url.com/test-url', }); collection = Object.assign(new Collection(), { - uuid: 'fake-collection-id' + uuid: 'fake-collection-id', }); collectionService = jasmine.createSpyObj('collectionService', { getContentSource: createSuccessfulRemoteDataObject$(contentSource), updateContentSource: observableOf(contentSource), - getHarvesterEndpoint: observableOf('harvester-endpoint') + getHarvesterEndpoint: observableOf('harvester-endpoint'), }); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); @@ -129,9 +152,9 @@ describe('CollectionSourceComponent', () => { { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: Router, useValue: router }, { provide: CollectionDataService, useValue: collectionService }, - { provide: RequestService, useValue: requestService } + { provide: RequestService, useValue: requestService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index 2d1308cc83..168ef543e8 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,5 +1,14 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { Location } from '@angular/common'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { DynamicFormControlModel, DynamicFormGroupModel, @@ -8,29 +17,46 @@ import { DynamicInputModel, DynamicOptionControlModel, DynamicRadioGroupModel, - DynamicSelectModel + DynamicSelectModel, } from '@ng-dynamic-forms/core'; -import { Location } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { UntypedFormGroup } from '@angular/forms'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; -import { Observable, Subscription } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Collection } from '../../../core/shared/collection.model'; -import { first, map, switchMap, take } from 'rxjs/operators'; -import { ActivatedRoute, Router } from '@angular/router'; import cloneDeep from 'lodash/cloneDeep'; -import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../../../core/shared/operators'; -import { MetadataConfig } from '../../../core/shared/metadata-config.model'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { RequestService } from '../../../core/data/request.service'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + first, + map, + switchMap, + take, +} from 'rxjs/operators'; + import { environment } from '../../../../environments/environment'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { + ContentSource, + ContentSourceHarvestType, +} from '../../../core/shared/content-source.model'; +import { MetadataConfig } from '../../../core/shared/metadata-config.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, +} from '../../../core/shared/operators'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; /** * Component for managing the content source of the collection @@ -84,11 +110,11 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem name: 'oaiSource', required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'You must provide a set id of the target collection.' - } + required: 'You must provide a set id of the target collection.', + }, }); /** @@ -96,7 +122,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ oaiSetIdModel = new DynamicInputModel({ id: 'oaiSetId', - name: 'oaiSetId' + name: 'oaiSetId', }); /** @@ -104,7 +130,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ metadataConfigIdModel = new DynamicSelectModel({ id: 'metadataConfigId', - name: 'metadataConfigId' + name: 'metadataConfigId', }); /** @@ -115,15 +141,15 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem name: 'harvestType', options: [ { - value: ContentSourceHarvestType.Metadata + value: ContentSourceHarvestType.Metadata, }, { - value: ContentSourceHarvestType.MetadataAndRef + value: ContentSourceHarvestType.MetadataAndRef, }, { - value: ContentSourceHarvestType.MetadataAndBitstreams - } - ] + value: ContentSourceHarvestType.MetadataAndBitstreams, + }, + ], }); /** @@ -139,22 +165,22 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem new DynamicFormGroupModel({ id: 'oaiSourceContainer', group: [ - this.oaiSourceModel - ] + this.oaiSourceModel, + ], }), new DynamicFormGroupModel({ id: 'oaiSetContainer', group: [ this.oaiSetIdModel, - this.metadataConfigIdModel - ] + this.metadataConfigIdModel, + ], }), new DynamicFormGroupModel({ id: 'harvestTypeContainer', group: [ - this.harvestTypeModel - ] - }) + this.harvestTypeModel, + ], + }), ]; /** @@ -163,40 +189,40 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem formLayout: DynamicFormLayout = { oaiSource: { grid: { - host: 'col-12 d-inline-block' - } + host: 'col-12 d-inline-block', + }, }, oaiSetId: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, metadataConfigId: { grid: { - host: 'col col-sm-6 d-inline-block' - } + host: 'col col-sm-6 d-inline-block', + }, }, harvestType: { grid: { host: 'col-12', - option: 'btn-outline-secondary' - } + option: 'btn-outline-secondary', + }, }, oaiSetContainer: { grid: { - host: 'row' - } + host: 'row', + }, }, oaiSourceContainer: { grid: { - host: 'row' - } + host: 'row', + }, }, harvestTypeContainer: { grid: { - host: 'row' - } - } + host: 'row', + }, + }, }; /** @@ -279,7 +305,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem const initialContentSource = cloneDeep(this.contentSource); this.objectUpdatesService.initialize(this.url, [initialContentSource], new Date()); this.update$ = this.objectUpdatesService.getFieldUpdates(this.url, [initialContentSource]).pipe( - map((updates: FieldUpdates) => updates[initialContentSource.uuid]) + map((updates: FieldUpdates) => updates[initialContentSource.uuid]), ); this.updateSub = this.update$.subscribe((update: FieldUpdate) => { if (update) { @@ -294,15 +320,15 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (hasValue(field)) { this.formGroup.patchValue({ oaiSourceContainer: { - oaiSource: field.oaiSource + oaiSource: field.oaiSource, }, oaiSetContainer: { oaiSetId: field.oaiSetId, - metadataConfigId: configId + metadataConfigId: configId, }, harvestTypeContainer: { - harvestType: field.harvestType - } + harvestType: field.harvestType, + }, }); this.contentSource = cloneDeep(field); } @@ -320,8 +346,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (this.metadataConfigIdModel.options.length > 0) { this.formGroup.patchValue({ oaiSetContainer: { - metadataConfigId: this.metadataConfigIdModel.options[0].value - } + metadataConfigId: this.metadataConfigIdModel.options[0].value, + }, }); } } @@ -333,7 +359,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.inputModels.forEach( (fieldModel: DynamicFormControlModel) => { this.updateFieldTranslation(fieldModel); - } + }, ); } @@ -378,7 +404,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem getFirstSucceededRemoteData(), map((col) => col.payload.uuid), switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), - take(1) + take(1), ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href); // Update harvester @@ -386,7 +412,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem getFirstSucceededRemoteData(), map((col) => col.payload.uuid), switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), - take(1) + take(1), ).subscribe((result: ContentSource | INotification) => { if (hasValue((result as any).harvestType)) { this.clearNotifications(); @@ -433,7 +459,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.inputModels.forEach( (fieldModel: DynamicInputModel) => { this.updateContentSourceField(fieldModel, updateHarvestType); - } + }, ); this.saveFieldUpdate(); } diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts index 00f05f50c0..f3d51f3f61 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.component.spec.ts @@ -1,39 +1,44 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; -import { SharedModule } from '../../shared/shared.module'; -import { CollectionDataService } from '../../core/data/collection-data.service'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { SharedModule } from '../../shared/shared.module'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; + describe('EditCollectionPageComponent', () => { let comp: EditCollectionPageComponent; let fixture: ComponentFixture; const routeStub = { data: observableOf({ - dso: { payload: {} } + dso: { payload: {} }, }), routeConfig: { children: [ { path: 'mockUrl', data: { - hideReturnButton: false - } - } - ] + hideReturnButton: false, + }, + }, + ], }, snapshot: { firstChild: { routeConfig: { - path: 'mockUrl' - } - } - } + path: 'mockUrl', + }, + }, + }, }; beforeEach(waitForAsync(() => { @@ -44,7 +49,7 @@ describe('EditCollectionPageComponent', () => { { provide: CollectionDataService, useValue: {} }, { provide: ActivatedRoute, useValue: routeStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts index 62fbb3ee3d..ca0a98be72 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; + import { Collection } from '../../core/shared/collection.model'; +import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCollectionPageRoute } from '../collection-page-routing-paths'; /** @@ -9,14 +13,14 @@ import { getCollectionPageRoute } from '../collection-page-routing-paths'; */ @Component({ selector: 'ds-edit-collection', - templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html', }) export class EditCollectionPageComponent extends EditComColPageComponent { type = 'collection'; public constructor( protected router: Router, - protected route: ActivatedRoute + protected route: ActivatedRoute, ) { super(router, route); } diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts index 8d0cb179f1..e3791011b1 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts @@ -1,22 +1,21 @@ -import { NgModule } from '@angular/core'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { AccessControlFormModule } from '../../shared/access-control-form-container/access-control-form.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { FormModule } from '../../shared/form/form.module'; +import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { SharedModule } from '../../shared/shared.module'; -import { EditCollectionPageRoutingModule } from './edit-collection-page.routing.module'; +import { CollectionFormModule } from '../collection-form/collection-form.module'; +import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; +import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; +import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; -import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; import { CollectionSourceComponent } from './collection-source/collection-source.component'; -import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; -import { CollectionFormModule } from '../collection-form/collection-form.module'; -import { - CollectionSourceControlsComponent -} from './collection-source/collection-source-controls/collection-source-controls.component'; -import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; -import { FormModule } from '../../shared/form/form.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; -import { AccessControlFormModule } from '../../shared/access-control-form-container/access-control-form.module'; +import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; +import { EditCollectionPageRoutingModule } from './edit-collection-page.routing.module'; /** * Module that contains all components related to the Edit Collection page administrator functionality @@ -40,8 +39,8 @@ import { AccessControlFormModule } from '../../shared/access-control-form-contai CollectionSourceComponent, CollectionAccessControlComponent, CollectionSourceControlsComponent, - CollectionAuthorizationsComponent - ] + CollectionAuthorizationsComponent, + ], }) export class EditCollectionPageModule { diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts index c4481985c0..3a5aae54ed 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts @@ -1,19 +1,20 @@ -import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; import { CollectionItemMapperComponent } from '../collection-item-mapper/collection-item-mapper.component'; -import { EditCollectionPageComponent } from './edit-collection-page.component'; +import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; +import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; +import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; import { CollectionSourceComponent } from './collection-source/collection-source.component'; -import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; -import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; -import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; -import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; -import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; -import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; -import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard'; -import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; /** * Routing module that handles the routing for the Edit Collection page administrator functionality @@ -24,7 +25,7 @@ import { CollectionAccessControlComponent } from './collection-access-control/co { path: '', resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { breadcrumbKey: 'collection.edit' }, component: EditCollectionPageComponent, @@ -33,7 +34,7 @@ import { CollectionAccessControlComponent } from './collection-access-control/co { path: '', redirectTo: 'metadata', - pathMatch: 'full' + pathMatch: 'full', }, { path: 'metadata', @@ -41,30 +42,30 @@ import { CollectionAccessControlComponent } from './collection-access-control/co data: { title: 'collection.edit.tabs.metadata.title', hideReturnButton: true, - showBreadcrumbs: true - } + showBreadcrumbs: true, + }, }, { path: 'roles', component: CollectionRolesComponent, - data: { title: 'collection.edit.tabs.roles.title', showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.roles.title', showBreadcrumbs: true }, }, { path: 'source', component: CollectionSourceComponent, - data: { title: 'collection.edit.tabs.source.title', showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.source.title', showBreadcrumbs: true }, }, { path: 'curate', component: CollectionCurateComponent, - data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true }, }, { path: 'access-control', component: CollectionAccessControlComponent, - data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true }, }, -/* { + /* { path: 'authorizations', component: CollectionAuthorizationsComponent, data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true } @@ -76,39 +77,39 @@ import { CollectionAccessControlComponent } from './collection-access-control/co { path: 'create', resolve: { - resourcePolicyTarget: ResourcePolicyTargetResolver + resourcePolicyTarget: ResourcePolicyTargetResolver, }, component: ResourcePolicyCreateComponent, - data: { title: 'resource-policies.create.page.title' } + data: { title: 'resource-policies.create.page.title' }, }, { path: 'edit', resolve: { - resourcePolicy: ResourcePolicyResolver + resourcePolicy: ResourcePolicyResolver, }, component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title' } + data: { title: 'resource-policies.edit.page.title' }, }, { path: '', component: CollectionAuthorizationsComponent, - data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true } - } - ] + data: { title: 'collection.edit.tabs.authorizations.title', showBreadcrumbs: true }, + }, + ], }, { path: 'mapper', component: CollectionItemMapperComponent, - data: { title: 'collection.edit.tabs.item-mapper.title', hideReturnButton: true, showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.item-mapper.title', hideReturnButton: true, showBreadcrumbs: true }, }, - ] - } - ]) + ], + }, + ]), ], providers: [ ResourcePolicyResolver, - ResourcePolicyTargetResolver - ] + ResourcePolicyTargetResolver, + ], }) export class EditCollectionPageRoutingModule { diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html index 20afd701ff..8d095dd229 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html @@ -2,7 +2,7 @@
-

{{ '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.spec.ts b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts index 72b776dd7d..068f799681 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts @@ -1,16 +1,24 @@ -import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../shared/shared.module'; -import { RouterTestingModule } from '@angular/router/testing'; import { CommonModule } from '@angular/common'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -import { ActivatedRoute } from '@angular/router'; -import { of as observableOf } from 'rxjs'; -import { Collection } from '../../core/shared/collection.model'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; +import { Collection } from '../../core/shared/collection.model'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { SharedModule } from '../../shared/shared.module'; import { getCollectionEditRoute } from '../collection-page-routing-paths'; +import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; describe('EditItemTemplatePageComponent', () => { let comp: EditItemTemplatePageComponent; @@ -22,19 +30,19 @@ describe('EditItemTemplatePageComponent', () => { collection = Object.assign(new Collection(), { uuid: 'collection-id', id: 'collection-id', - name: 'Fake Collection' + name: 'Fake Collection', }); itemTemplateService = jasmine.createSpyObj('itemTemplateService', { - findByCollectionID: createSuccessfulRemoteDataObject$({}) + findByCollectionID: createSuccessfulRemoteDataObject$({}), }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], declarations: [EditItemTemplatePageComponent], providers: [ { provide: ItemTemplateDataService, useValue: itemTemplateService }, - { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } } + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); 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..9dfe14e72d 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 @@ -1,15 +1,23 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; +import { + first, + map, + switchMap, +} from 'rxjs/operators'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; -import { ActivatedRoute } from '@angular/router'; -import { first, map, switchMap } from 'rxjs/operators'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -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 { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { AlertType } from '../../shared/alert/alert-type'; +import { getCollectionEditRoute } from '../collection-page-routing-paths'; @Component({ selector: 'ds-edit-item-template-page', diff --git a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts index 95f0d888e4..ced1ca9323 100644 --- a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts +++ b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.spec.ts @@ -1,9 +1,9 @@ import { first } from 'rxjs/operators'; -import { ItemTemplatePageResolver } from './item-template-page.resolver'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ItemTemplatePageResolver } from './item-template-page.resolver'; describe('ItemTemplatePageResolver', () => { describe('resolve', () => { @@ -14,7 +14,7 @@ describe('ItemTemplatePageResolver', () => { beforeEach(() => { itemTemplateService = { - findByCollectionID: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findByCollectionID: (id: string) => createSuccessfulRemoteDataObject$({ id }), }; dsoNameService = new DSONameServiceMock(); resolver = new ItemTemplatePageResolver(dsoNameService as DSONameService, itemTemplateService); @@ -27,7 +27,7 @@ describe('ItemTemplatePageResolver', () => { (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts index 586617c44c..08553d9e93 100644 --- a/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts +++ b/src/app/collection-page/edit-item-template-page/item-template-page.resolver.ts @@ -1,12 +1,17 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -import { Observable } from 'rxjs'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { followLink } from '../../shared/utils/follow-link-config.model'; /** * This class represents a resolver that requests a specific collection's item template before the route is activated diff --git a/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts index b53f4e6c45..3cf4eafdc7 100644 --- a/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts +++ b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; diff --git a/src/app/collection-page/themed-collection-page.component.ts b/src/app/collection-page/themed-collection-page.component.ts index 2faf418423..ff4e246e7d 100644 --- a/src/app/collection-page/themed-collection-page.component.ts +++ b/src/app/collection-page/themed-collection-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CollectionPageComponent } from './collection-page.component'; diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index e2a2bb748f..95acc9dd86 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,10 +1,18 @@ -import { hasValue } from '../shared/empty.util'; -import { CommunityListService} from './community-list-service'; -import { CollectionViewer, DataSource } from '@angular/cdk/collections'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { + CollectionViewer, + DataSource, +} from '@angular/cdk/collections'; +import { + BehaviorSubject, + Observable, + Subscription, +} from 'rxjs'; import { finalize } from 'rxjs/operators'; -import { FlatNode } from './flat-node.model'; + import { FindListOptions } from '../core/data/find-list-options.model'; +import { hasValue } from '../shared/empty.util'; +import { CommunityListService } from './community-list-service'; +import { FlatNode } from './flat-node.model'; /** * DataSource object needed by a CDK Tree to render its nodes. 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-page.component.spec.ts b/src/app/community-list-page/community-list-page.component.spec.ts index 080a0a9e18..60933a0677 100644 --- a/src/app/community-list-page/community-list-page.component.spec.ts +++ b/src/app/community-list-page/community-list-page.component.spec.ts @@ -1,9 +1,17 @@ -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; - -import { CommunityListPageComponent } from './community-list-page.component'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { CommunityListPageComponent } from './community-list-page.component'; describe('CommunityListPageComponent', () => { let component: CommunityListPageComponent; @@ -15,7 +23,7 @@ describe('CommunityListPageComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock + useClass: TranslateLoaderMock, }, }), ], diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 15946b2e89..330e3f0346 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -1,19 +1,20 @@ +import { CdkTreeModule } from '@angular/cdk/tree'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; + import { SharedModule } from '../shared/shared.module'; +import { CommunityListComponent } from './community-list/community-list.component'; +import { ThemedCommunityListComponent } from './community-list/themed-community-list.component'; import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; -import { CommunityListComponent } from './community-list/community-list.component'; import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; -import { ThemedCommunityListComponent } from './community-list/themed-community-list.component'; -import { CdkTreeModule } from '@angular/cdk/tree'; const DECLARATIONS = [ CommunityListPageComponent, CommunityListComponent, ThemedCommunityListPageComponent, - ThemedCommunityListComponent + ThemedCommunityListComponent, ]; /** * The page which houses a title and the community list, as described in community-list.component @@ -26,7 +27,7 @@ const DECLARATIONS = [ CdkTreeModule, ], declarations: [ - ...DECLARATIONS + ...DECLARATIONS, ], exports: [ ...DECLARATIONS, diff --git a/src/app/community-list-page/community-list-page.routing.module.ts b/src/app/community-list-page/community-list-page.routing.module.ts index 1754b1b7cf..98d305c312 100644 --- a/src/app/community-list-page/community-list-page.routing.module.ts +++ b/src/app/community-list-page/community-list-page.routing.module.ts @@ -1,10 +1,10 @@ +import { CdkTreeModule } from '@angular/cdk/tree'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { CdkTreeModule } from '@angular/cdk/tree'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { CommunityListService } from './community-list-service'; import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; /** * RouterModule to help navigate to the page with the community list tree @@ -17,14 +17,14 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso component: ThemedCommunityListPageComponent, pathMatch: 'full', resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, - data: { title: 'communityList.tabTitle', breadcrumbKey: 'communityList' } - } + data: { title: 'communityList.tabTitle', breadcrumbKey: 'communityList' }, + }, ]), CdkTreeModule, ], - providers: [CommunityListService] + providers: [CommunityListService], }) export class CommunityListPageRoutingModule { } diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index 410dd9f804..28d3cfe1a9 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -1,23 +1,36 @@ -import { inject, TestBed } from '@angular/core/testing'; +import { + inject, + TestBed, +} from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { take } from 'rxjs/operators'; -import { AppState } from '../app.reducer'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { buildPaginatedList } from '../core/data/paginated-list.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { StoreMock } from '../shared/testing/store.mock'; -import { CommunityListService, toFlatNode } from './community-list-service'; -import { CollectionDataService } from '../core/data/collection-data.service'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { Community } from '../core/shared/community.model'; -import { Collection } from '../core/shared/collection.model'; -import { PageInfo } from '../core/shared/page-info.model'; -import { FlatNode } from './flat-node.model'; -import { FindListOptions } from '../core/data/find-list-options.model'; import { APP_CONFIG } from 'src/config/app-config.interface'; import { environment } from 'src/environments/environment.test'; +import { AppState } from '../app.reducer'; +import { + SortDirection, + SortOptions, +} from '../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../core/data/collection-data.service'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { buildPaginatedList } from '../core/data/paginated-list.model'; +import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; +import { PageInfo } from '../core/shared/page-info.model'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../shared/remote-data.utils'; +import { StoreMock } from '../shared/testing/store.mock'; +import { + CommunityListService, + toFlatNode, +} from './community-list-service'; +import { FlatNode } from './flat-node.model'; + describe('CommunityListService', () => { let store: StoreMock; const standardElementsPerPage = 2; @@ -38,34 +51,34 @@ describe('CommunityListService', () => { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', }), - Object.assign(new Community(), { - id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - }) + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + }), ]; mockCollectionsPage1 = [ Object.assign(new Collection(), { id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed', - name: 'Collection 1' + name: 'Collection 1', }), Object.assign(new Collection(), { id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', - name: 'Collection 2' - }) + name: 'Collection 2', + }), ]; mockCollectionsPage2 = [ Object.assign(new Collection(), { id: 'a5159760-f362-4659-9e81-e3253ad91ede', uuid: 'a5159760-f362-4659-9e81-e3253ad91ede', - name: 'Collection 3' + name: 'Collection 3', }), Object.assign(new Collection(), { id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', - name: 'Collection 4' - }) + name: 'Collection 4', + }), ]; mockListOfTopCommunitiesPage1 = [ Object.assign(new Community(), { @@ -164,7 +177,7 @@ describe('CommunityListService', () => { } else { return createFailedRemoteDataObject$(); } - } + }, }; collectionDataServiceStub = { findByParent(parentUUID: string, options: FindListOptions = {}) { @@ -189,7 +202,7 @@ describe('CommunityListService', () => { } else { return createFailedRemoteDataObject$(); } - } + }, }; TestBed.configureTestingModule({ providers: [CommunityListService, @@ -217,7 +230,7 @@ describe('CommunityListService', () => { service.loadCommunities({ currentPage: 2, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, null) .pipe(take(1)) .subscribe((value) => { @@ -246,7 +259,7 @@ describe('CommunityListService', () => { beforeEach((done) => { service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, null) .pipe(take(1)) .subscribe((value) => { @@ -279,7 +292,7 @@ describe('CommunityListService', () => { }); service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -307,7 +320,7 @@ describe('CommunityListService', () => { const expandedNodes = [communityFlatNode]; service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -332,7 +345,7 @@ describe('CommunityListService', () => { const expandedNodes = [communityFlatNode]; service.loadCommunities({ currentPage: 1, - sort: new SortOptions('dc.title', SortDirection.ASC) + sort: new SortOptions('dc.title', SortDirection.ASC), }, expandedNodes) .pipe(take(1)) .subscribe((value) => { @@ -429,8 +442,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 2' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 2' }], + }, }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { @@ -461,8 +474,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { @@ -495,8 +508,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); let flatNodeList; beforeEach((done) => { @@ -540,8 +553,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); const communityFlatNode = toFlatNode(communityWithCollections, observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 2; @@ -591,8 +604,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: '2 subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 1' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 1' }], + }, }); service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); @@ -607,8 +620,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockCollectionsPage1)), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, 2 coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 2' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 2' }], + }, }); service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); @@ -625,8 +638,8 @@ describe('CommunityListService', () => { collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.description': [{ language: 'en_US', value: 'no subcoms, no coll' }], - 'dc.title': [{ language: 'en_US', value: 'Community 3' }] - } + 'dc.title': [{ language: 'en_US', value: 'Community 3' }], + }, }); service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { expect(result).toEqual(false); diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 99e9dbeb0d..705dcc7e5d 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -1,31 +1,57 @@ /* eslint-disable max-classes-per-file */ -import { Inject, Injectable } from '@angular/core'; -import { createSelector, Store } from '@ngrx/store'; - -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { filter, map, switchMap } from 'rxjs/operators'; +import { + Inject, + Injectable, +} from '@angular/core'; +import { + createSelector, + Store, +} from '@ngrx/store'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + switchMap, +} from 'rxjs/operators'; +import { + APP_CONFIG, + AppConfig, +} from 'src/config/app-config.interface'; +import { v4 as uuidv4 } from 'uuid'; import { AppState } from '../app.reducer'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { Community } from '../core/shared/community.model'; -import { Collection } from '../core/shared/collection.model'; -import { PageInfo } from '../core/shared/page-info.model'; -import { hasValue, isNotEmpty } from '../shared/empty.util'; -import { RemoteData } from '../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; +import { getCollectionPageRoute } from '../collection-page/collection-page-routing-paths'; +import { getCommunityPageRoute } from '../community-page/community-page-routing-paths'; import { CollectionDataService } from '../core/data/collection-data.service'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; +import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, +} from '../core/shared/operators'; +import { PageInfo } from '../core/shared/page-info.model'; +import { + hasValue, + isNotEmpty, +} from '../shared/empty.util'; +import { followLink } from '../shared/utils/follow-link-config.model'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListState } from './community-list.reducer'; -import { getCommunityPageRoute } from '../community-page/community-page-routing-paths'; -import { getCollectionPageRoute } from '../collection-page/collection-page-routing-paths'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../core/shared/operators'; -import { followLink } from '../shared/utils/follow-link-config.model'; 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'; -// 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)), @@ -45,7 +71,7 @@ export const toFlatNode = ( isExpandable: Observable, level: number, isExpanded: boolean, - parent?: FlatNode + parent?: FlatNode, ): FlatNode => ({ isExpandable$: isExpandable, name: c.name, @@ -64,7 +90,7 @@ export const toFlatNode = ( export const showMoreFlatNode = ( id: string, level: number, - parent: FlatNode + parent: FlatNode, ): FlatNode => ({ isExpandable$: observableOf(false), name: 'Show More Flatnode', @@ -94,13 +120,13 @@ export class CommunityListService { @Inject(APP_CONFIG) protected appConfig: AppConfig, private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, - private store: Store + private store: Store, ) { this.pageSize = appConfig.communityList.pageSize; } private configOnePage: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 1 + elementsPerPage: 1, }); saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { @@ -137,7 +163,7 @@ export class CommunityListService { newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage }); } return buildPaginatedList(newPageInfo, newPage); - }) + }), ); return topComs$.pipe( switchMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes)), @@ -150,15 +176,15 @@ export class CommunityListService { */ private getTopCommunities(options: FindListOptions): Observable> { return this.communityDataService.findTop({ - currentPage: options.currentPage, - elementsPerPage: this.pageSize, - sort: { - field: options.sort.field, - direction: options.sort.direction - } + currentPage: options.currentPage, + elementsPerPage: this.pageSize, + sort: { + field: options.sort.field, + direction: options.sort.direction, }, - followLink('subcommunities', { findListOptions: this.configOnePage }), - followLink('collections', { findListOptions: this.configOnePage })) + }, + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstSucceededRemoteData(), map((results) => results.payload), @@ -173,9 +199,9 @@ export class CommunityListService { * @param expandedNodes List of expanded nodes; if a node is not expanded its subcommunities and collections need not be added to the list */ public transformListOfCommunities(listOfPaginatedCommunities: PaginatedList, - level: number, - parent: FlatNode, - expandedNodes: FlatNode[]): Observable { + level: number, + parent: FlatNode, + expandedNodes: FlatNode[]): Observable { if (isNotEmpty(listOfPaginatedCommunities.page)) { let currentPage = listOfPaginatedCommunities.currentPage; if (isNotEmpty(parent)) { @@ -186,7 +212,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 +225,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 @@ -222,11 +248,11 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: this.pageSize, - currentPage: i - }, - followLink('subcommunities', { findListOptions: this.configOnePage }), - followLink('collections', { findListOptions: this.configOnePage })) + elementsPerPage: this.pageSize, + currentPage: i, + }, + followLink('subcommunities', { findListOptions: this.configOnePage }), + followLink('collections', { findListOptions: this.configOnePage })) .pipe( getFirstCompletedRemoteData(), switchMap((rd: RemoteData>) => { @@ -235,7 +261,7 @@ export class CommunityListService { } else { return observableOf([]); } - }) + }), ); subcoms = [...subcoms, nextSetOfSubcommunitiesPage]; @@ -248,7 +274,7 @@ export class CommunityListService { for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: this.pageSize, - currentPage: i + currentPage: i, }) .pipe( getFirstCompletedRemoteData(), @@ -257,7 +283,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,14 +301,12 @@ 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) */ public getIsExpandable(community: Community): Observable { - let hasSubcoms$: Observable; - let hasColls$: Observable; - hasSubcoms$ = this.communityDataService.findByParent(community.uuid, this.configOnePage) + const hasSubcoms$ = this.communityDataService.findByParent(community.uuid, this.configOnePage) .pipe( map((rd: RemoteData>) => { if (hasValue(rd) && hasValue(rd.payload)) { @@ -293,7 +317,7 @@ export class CommunityListService { }), ); - hasColls$ = this.collectionDataService.findByParent(community.uuid, this.configOnePage) + const hasColls$ = this.collectionDataService.findByParent(community.uuid, this.configOnePage) .pipe( map((rd: RemoteData>) => { if (hasValue(rd) && hasValue(rd.payload)) { @@ -304,12 +328,9 @@ export class CommunityListService { }), ); - let hasChildren$: Observable; - hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( - map(([hasSubcoms, hasColls]: [boolean, boolean]) => hasSubcoms || hasColls) + return observableCombineLatest(hasSubcoms$, hasColls$).pipe( + map(([hasSubcoms, hasColls]: [boolean, boolean]) => hasSubcoms || hasColls), ); - - return hasChildren$; } } diff --git a/src/app/community-list-page/community-list.actions.ts b/src/app/community-list-page/community-list.actions.ts index 8e8d6d87cf..47c72af9f7 100644 --- a/src/app/community-list-page/community-list.actions.ts +++ b/src/app/community-list-page/community-list.actions.ts @@ -1,4 +1,5 @@ import { Action } from '@ngrx/store'; + import { type } from '../shared/ngrx/type'; import { FlatNode } from './flat-node.model'; @@ -7,7 +8,7 @@ import { FlatNode } from './flat-node.model'; */ export const CommunityListActionTypes = { - SAVE: type('dspace/community-list-page/SAVE') + SAVE: type('dspace/community-list-page/SAVE'), }; /** diff --git a/src/app/community-list-page/community-list.reducer.spec.ts b/src/app/community-list-page/community-list.reducer.spec.ts index 0d0f5c7580..abbd16d4cd 100644 --- a/src/app/community-list-page/community-list.reducer.spec.ts +++ b/src/app/community-list-page/community-list.reducer.spec.ts @@ -1,11 +1,12 @@ import { of as observableOf } from 'rxjs'; + import { buildPaginatedList } from '../core/data/paginated-list.model'; import { Community } from '../core/shared/community.model'; import { PageInfo } from '../core/shared/page-info.model'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { toFlatNode } from './community-list-service'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListReducer } from './community-list.reducer'; +import { toFlatNode } from './community-list-service'; describe('communityListReducer', () => { const mockSubcommunities1Page1 = [Object.assign(new Community(), { @@ -20,7 +21,7 @@ describe('communityListReducer', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockSubcommunities1Page1)), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community1', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ); it ('should set init state of the expandedNodes and loadingNode', () => { diff --git a/src/app/community-list-page/community-list.reducer.ts b/src/app/community-list-page/community-list.reducer.ts index 99c8350cf4..7afcabf067 100644 --- a/src/app/community-list-page/community-list.reducer.ts +++ b/src/app/community-list-page/community-list.reducer.ts @@ -1,4 +1,8 @@ -import { CommunityListActions, CommunityListActionTypes, CommunityListSaveAction } from './community-list.actions'; +import { + CommunityListActions, + CommunityListActionTypes, + CommunityListSaveAction, +} from './community-list.actions'; import { FlatNode } from './flat-node.model'; /** 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..ea34e02d56 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..434a14a85f 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 @@ -1,22 +1,43 @@ -import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; - -import { CommunityListComponent } from './community-list.component'; -import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community-list-service'; 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 { RouterTestingModule } from '@angular/router/testing'; -import { Community } from '../../core/shared/community.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 { Collection } from '../../core/shared/collection.model'; -import { of as observableOf } from 'rxjs'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; 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 { RouterTestingModule } from '@angular/router/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { Collection } from '../../core/shared/collection.model'; +import { Community } from '../../core/shared/community.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + CommunityListService, + showMoreFlatNode, + toFlatNode, +} from '../community-list-service'; +import { FlatNode } from '../flat-node.model'; +import { CommunityListComponent } from './community-list.component'; describe('CommunityListComponent', () => { let component: CommunityListComponent; @@ -27,11 +48,11 @@ describe('CommunityListComponent', () => { uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'subcommunity1', }), - Object.assign(new Community(), { - id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', - name: 'subcommunity2', - }) + Object.assign(new Community(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + uuid: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'subcommunity2', + }), ]; const mockCollectionsPage1 = [ Object.assign(new Collection(), { @@ -43,7 +64,7 @@ describe('CommunityListComponent', () => { id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', uuid: '59da2ff0-9bf4-45bf-88be-e35abd33f304', name: 'collection2', - }) + }), ]; const mockCollectionsPage2 = [ Object.assign(new Collection(), { @@ -55,7 +76,7 @@ describe('CommunityListComponent', () => { id: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', uuid: 'a392e16b-fcf2-400a-9a88-53ef7ecbdcd3', name: 'collection4', - }) + }), ]; const mockTopCommunitiesWithChildrenArrays = [ @@ -86,7 +107,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockSubcommunities1Page1)), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community1', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -95,7 +116,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [...mockCollectionsPage1, ...mockCollectionsPage2])), name: 'community2', - }), observableOf(true), 0, false, null + }), observableOf(true), 0, false, null, ), toFlatNode( Object.assign(new Community(), { @@ -104,7 +125,7 @@ describe('CommunityListComponent', () => { subcommunities: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), collections: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), name: 'community3', - }), observableOf(false), 0, false, null + }), observableOf(false), 0, false, null, ), ]; let communityListServiceStub; @@ -138,7 +159,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,32 +186,32 @@ 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); } - } + }, }; TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock + useClass: TranslateLoaderMock, }, }), CdkTreeModule, @@ -198,7 +219,7 @@ describe('CommunityListComponent', () => { RouterLinkWithHref], declarations: [CommunityListComponent], providers: [CommunityListComponent, - { provide: CommunityListService, useValue: communityListServiceStub },], + { provide: CommunityListService, useValue: communityListServiceStub }], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .compileComponents(); @@ -242,7 +263,7 @@ describe('CommunityListComponent', () => { const showMoreLink = fixture.debugElement.query(By.css('.show-more-node .btn-outline-primary')); showMoreLink.triggerEventHandler('click', { preventDefault: () => {/**/ - } + }, }); tick(); fixture.detectChanges(); @@ -299,12 +320,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 +337,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..369e51ea12 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 @@ -1,13 +1,21 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { take } from 'rxjs/operators'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityListService} from '../community-list-service'; -import { CommunityListDatasource } from '../community-list-datasource'; import { FlatTreeControl } from '@angular/cdk/tree'; -import { isEmpty } from '../../shared/empty.util'; -import { FlatNode } from '../flat-node.model'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { take } from 'rxjs/operators'; + import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { + SortDirection, + SortOptions, +} from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { isEmpty } from '../../shared/empty.util'; +import { CommunityListDatasource } from '../community-list-datasource'; +import { CommunityListService } from '../community-list-service'; +import { FlatNode } from '../flat-node.model'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. @@ -19,6 +27,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 { @@ -26,12 +35,11 @@ export class CommunityListComponent implements OnInit, OnDestroy { public loadingNode: FlatNode; treeControl = new FlatTreeControl( - (node: FlatNode) => node.level, (node: FlatNode) => true + (node: FlatNode) => node.level, (node: FlatNode) => true, ); - dataSource: CommunityListDatasource; - paginationConfig: FindListOptions; + trackBy = (index, node: FlatNode) => node.id; constructor( protected communityListService: CommunityListService, @@ -58,24 +66,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 +110,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/community-list/themed-community-list.component.ts b/src/app/community-list-page/community-list/themed-community-list.component.ts index 4a986e737c..9e494bdf47 100644 --- a/src/app/community-list-page/community-list/themed-community-list.component.ts +++ b/src/app/community-list-page/community-list/themed-community-list.component.ts @@ -1,6 +1,7 @@ +import { Component } from '@angular/core'; + import { ThemedComponent } from '../../shared/theme-support/themed.component'; import { CommunityListComponent } from './community-list.component'; -import { Component } from '@angular/core'; @Component({ diff --git a/src/app/community-list-page/flat-node.model.ts b/src/app/community-list-page/flat-node.model.ts index 0aabbeb489..125ffc1e59 100644 --- a/src/app/community-list-page/flat-node.model.ts +++ b/src/app/community-list-page/flat-node.model.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs'; -import { Community } from '../core/shared/community.model'; + import { Collection } from '../core/shared/collection.model'; +import { Community } from '../core/shared/community.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model'; /** 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-list-page/themed-community-list-page.component.ts b/src/app/community-list-page/themed-community-list-page.component.ts index 20fa97bedd..2b5113bac9 100644 --- a/src/app/community-list-page/themed-community-list-page.component.ts +++ b/src/app/community-list-page/themed-community-list-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityListPageComponent } from './community-list-page.component'; diff --git a/src/app/community-page/community-form/community-form.component.ts b/src/app/community-page/community-form/community-form.component.ts index fa4809738d..0ac871ebb9 100644 --- a/src/app/community-page/community-form/community-form.component.ts +++ b/src/app/community-page/community-form/community-form.component.ts @@ -1,19 +1,26 @@ -import { Component, Input, OnChanges, SimpleChange, SimpleChanges } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChange, + SimpleChanges, +} from '@angular/core'; import { DynamicFormControlModel, DynamicFormService, DynamicInputModel, - DynamicTextAreaModel + DynamicTextAreaModel, } from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; + +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../core/auth/auth.service'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; import { Community } from '../../core/shared/community.model'; import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; -import { TranslateService } from '@ngx-translate/core'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { RequestService } from '../../core/data/request.service'; -import { ObjectCacheService } from '../../core/cache/object-cache.service'; -import { environment } from '../../../environments/environment'; /** * Form used for creating and editing communities @@ -21,7 +28,7 @@ import { environment } from '../../../environments/environment'; @Component({ selector: 'ds-community-form', styleUrls: ['../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.scss'], - templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html' + templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html', }) export class CommunityFormComponent extends ComColFormComponent implements OnChanges { /** @@ -44,10 +51,10 @@ export class CommunityFormComponent extends ComColFormComponent imple name: 'dc.title', required: true, validators: { - required: null + required: null, }, errorMessages: { - required: 'Please enter a name for this title' + required: 'Please enter a name for this title', }, }), new DynamicTextAreaModel({ @@ -85,7 +92,7 @@ export class CommunityFormComponent extends ComColFormComponent imple ngOnChanges(changes: SimpleChanges) { const dsoChange: SimpleChange = changes.dso; if (this.dso && dsoChange && !dsoChange.isFirstChange()) { - super.ngOnInit(); + super.ngOnInit(); } } } diff --git a/src/app/community-page/community-form/community-form.module.ts b/src/app/community-page/community-form/community-form.module.ts index 925d218973..fb337a7c98 100644 --- a/src/app/community-page/community-form/community-form.module.ts +++ b/src/app/community-page/community-form/community-form.module.ts @@ -1,22 +1,22 @@ import { NgModule } from '@angular/core'; -import { CommunityFormComponent } from './community-form.component'; -import { SharedModule } from '../../shared/shared.module'; import { ComcolModule } from '../../shared/comcol/comcol.module'; import { FormModule } from '../../shared/form/form.module'; +import { SharedModule } from '../../shared/shared.module'; +import { CommunityFormComponent } from './community-form.component'; @NgModule({ imports: [ ComcolModule, FormModule, - SharedModule + SharedModule, ], declarations: [ CommunityFormComponent, ], exports: [ - CommunityFormComponent - ] + CommunityFormComponent, + ], }) export class CommunityFormModule { diff --git a/src/app/community-page/community-page-administrator.guard.ts b/src/app/community-page/community-page-administrator.guard.ts index fd7ce5f7bf..4a3816b225 100644 --- a/src/app/community-page/community-page-administrator.guard.ts +++ b/src/app/community-page/community-page-administrator.guard.ts @@ -1,15 +1,23 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Community } from '../core/shared/community.model'; -import { CommunityPageResolver } from './community-page.resolver'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../core/auth/auth.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; -import { Observable, of as observableOf } from 'rxjs'; import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { AuthService } from '../core/auth/auth.service'; +import { Community } from '../core/shared/community.model'; +import { CommunityPageResolver } from './community-page.resolver'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) /** * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts index c37f8832f8..2d86223801 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -1,20 +1,29 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { CommunityPageResolver } from './community-page.resolver'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { BrowseByGuard } from '../browse-by/browse-by-guard'; +import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { LinkService } from '../core/cache/builders/link.service'; -import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; -import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; -import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; -import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; +import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; +import { CommunityPageResolver } from './community-page.resolver'; +import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; +import { + COMMUNITY_CREATE_PATH, + COMMUNITY_EDIT_PATH, +} from './community-page-routing-paths'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { ThemedCommunityPageComponent } from './themed-community-page.component'; @NgModule({ imports: [ @@ -22,14 +31,14 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; { path: COMMUNITY_CREATE_PATH, component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] + canActivate: [AuthenticatedGuard, CreateCommunityPageGuard], }, { path: ':id', resolve: { dso: CommunityPageResolver, breadcrumb: CommunityBreadcrumbResolver, - menu: DSOEditMenuResolver + menu: DSOEditMenuResolver, }, runGuardsAndResolvers: 'always', children: [ @@ -37,7 +46,7 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; path: COMMUNITY_EDIT_PATH, loadChildren: () => import('./edit-community-page/edit-community-page.module') .then((m) => m.EditCommunityPageModule), - canActivate: [CommunityPageAdministratorGuard] + canActivate: [CommunityPageAdministratorGuard], }, { path: 'delete', @@ -48,8 +57,33 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; { path: '', component: ThemedCommunityPageComponent, - pathMatch: 'full', - } + children: [ + { + path: '', + pathMatch: 'full', + component: ComcolSearchSectionComponent, + }, + { + path: 'subcoms-cols', + pathMatch: 'full', + component: SubComColSectionComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.subcoms-cols' }, + }, + { + path: 'browse/:id', + pathMatch: 'full', + component: ComcolBrowseByComponent, + canActivate: [BrowseByGuard], + resolve: { + breadcrumb: BrowseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'browse.metadata' }, + }, + ], + }, ], data: { menu: { @@ -67,7 +101,7 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; }, }, }, - ]) + ]), ], providers: [ CommunityPageResolver, @@ -76,7 +110,7 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; LinkService, CreateCommunityPageGuard, CommunityPageAdministratorGuard, - ] + ], }) export class CommunityPageRoutingModule { diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 6d5262d933..b3e577af7d 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -7,7 +7,7 @@ - + @@ -17,13 +17,10 @@ + [title]="'community.page.news'"> -
- -
@@ -31,10 +28,9 @@ - - +
-
+
diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index a5bbff3cee..916de6f67b 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -1,32 +1,38 @@ -import { mergeMap, filter, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - +import { + ChangeDetectionStrategy, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { RemoteData } from '../core/data/remote-data'; -import { Bitstream } from '../core/shared/bitstream.model'; +import { + filter, + map, + mergeMap, +} from 'rxjs/operators'; -import { Community } from '../core/shared/community.model'; - -import { MetadataService } from '../core/metadata/metadata.service'; - -import { fadeInOut } from '../shared/animations/fade'; -import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; import { AuthService } from '../core/auth/auth.service'; +import { DSONameService } from '../core/breadcrumbs/dso-name.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; -import { getCommunityPageRoute } from './community-page-routing-paths'; +import { RemoteData } from '../core/data/remote-data'; import { redirectOn4xx } from '../core/shared/authorized.operators'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; +import { Bitstream } from '../core/shared/bitstream.model'; +import { Community } from '../core/shared/community.model'; +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; +import { fadeInOut } from '../shared/animations/fade'; +import { hasValue } from '../shared/empty.util'; +import { getCommunityPageRoute } from './community-page-routing-paths'; @Component({ selector: 'ds-community-page', styleUrls: ['./community-page.component.scss'], templateUrl: './community-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [fadeInOut] + animations: [fadeInOut], }) /** * This component represents a detail page for a single community @@ -53,8 +59,6 @@ export class CommunityPageComponent implements OnInit { communityPageRoute$: Observable; constructor( - private communityDataService: CommunityDataService, - private metadata: MetadataService, private route: ActivatedRoute, private router: Router, private authService: AuthService, @@ -67,7 +71,7 @@ export class CommunityPageComponent implements OnInit { ngOnInit(): void { this.communityRD$ = this.route.data.pipe( map((data) => data.dso as RemoteData), - redirectOn4xx(this.router, this.authService) + redirectOn4xx(this.router, this.authService), ); this.logoRD$ = this.communityRD$.pipe( map((rd: RemoteData) => rd.payload), @@ -75,7 +79,7 @@ export class CommunityPageComponent implements OnInit { mergeMap((community: Community) => community.logo)); this.communityPageRoute$ = this.communityRD$.pipe( getAllSucceededRemoteDataPayload(), - map((community) => getCommunityPageRoute(community.id)) + map((community) => getCommunityPageRoute(community.id)), ); this.isCommunityAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCommunityAdmin); } diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts index 45ffb2a786..07434c9673 100644 --- a/src/app/community-page/community-page.module.ts +++ b/src/app/community-page/community-page.module.ts @@ -1,34 +1,34 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { BrowseByPageModule } from '../browse-by/browse-by-page.module'; +import { ComcolModule } from '../shared/comcol/comcol.module'; +import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { SharedModule } from '../shared/shared.module'; - -import { CommunityPageComponent } from './community-page.component'; -import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; -import { CommunityPageRoutingModule } from './community-page-routing.module'; -import { CommunityPageSubCommunityListComponent } from './sub-community-list/community-page-sub-community-list.component'; -import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; -import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; import { StatisticsModule } from '../statistics/statistics.module'; import { CommunityFormModule } from './community-form/community-form.module'; +import { CommunityPageComponent } from './community-page.component'; +import { CommunityPageRoutingModule } from './community-page-routing.module'; +import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; +import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { CommunityPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component'; +import { ThemedCollectionPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { CommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component'; +import { ThemedCommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; import { ThemedCommunityPageComponent } from './themed-community-page.component'; -import { ComcolModule } from '../shared/comcol/comcol.module'; -import { - ThemedCommunityPageSubCommunityListComponent -} from './sub-community-list/themed-community-page-sub-community-list.component'; -import { - ThemedCollectionPageSubCollectionListComponent -} from './sub-collection-list/themed-community-page-sub-collection-list.component'; -import { DsoPageModule } from '../shared/dso-page/dso-page.module'; -const DECLARATIONS = [CommunityPageComponent, +const DECLARATIONS = [ + CommunityPageComponent, ThemedCommunityPageComponent, ThemedCommunityPageSubCommunityListComponent, CommunityPageSubCollectionListComponent, ThemedCollectionPageSubCollectionListComponent, CommunityPageSubCommunityListComponent, CreateCommunityPageComponent, - DeleteCommunityPageComponent]; + DeleteCommunityPageComponent, + SubComColSectionComponent, +]; @NgModule({ imports: [ @@ -39,13 +39,14 @@ const DECLARATIONS = [CommunityPageComponent, CommunityFormModule, ComcolModule, DsoPageModule, + BrowseByPageModule, ], declarations: [ - ...DECLARATIONS + ...DECLARATIONS, ], exports: [ - ...DECLARATIONS - ] + ...DECLARATIONS, + ], }) export class CommunityPageModule { diff --git a/src/app/community-page/community-page.resolver.spec.ts b/src/app/community-page/community-page.resolver.spec.ts index f181dbfff6..70bb0075e3 100644 --- a/src/app/community-page/community-page.resolver.spec.ts +++ b/src/app/community-page/community-page.resolver.spec.ts @@ -1,6 +1,7 @@ import { first } from 'rxjs/operators'; -import { CommunityPageResolver } from './community-page.resolver'; + import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { CommunityPageResolver } from './community-page.resolver'; describe('CommunityPageResolver', () => { describe('resolve', () => { @@ -11,7 +12,7 @@ describe('CommunityPageResolver', () => { beforeEach(() => { communityService = { - findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }), }; store = jasmine.createSpyObj('store', { dispatch: {}, @@ -26,7 +27,7 @@ describe('CommunityPageResolver', () => { (resolved) => { expect(resolved.payload.id).toEqual(uuid); done(); - } + }, ); }); }); diff --git a/src/app/community-page/community-page.resolver.ts b/src/app/community-page/community-page.resolver.ts index 01de9294f3..bb90c4d457 100644 --- a/src/app/community-page/community-page.resolver.ts +++ b/src/app/community-page/community-page.resolver.ts @@ -1,13 +1,21 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../core/data/remote-data'; -import { Community } from '../core/shared/community.model'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { ResolvedAction } from '../core/resolving/resolver.actions'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { CommunityDataService } from '../core/data/community-data.service'; +import { RemoteData } from '../core/data/remote-data'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; +import { Community } from '../core/shared/community.model'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { + followLink, + FollowLinkConfig, +} from '../shared/utils/follow-link-config.model'; /** * The self links defined in this list are expected to be requested somewhere in the near future @@ -17,7 +25,7 @@ export const COMMUNITY_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ followLink('logo'), followLink('subcommunities'), followLink('collections'), - followLink('parentCommunity') + followLink('parentCommunity'), ]; /** @@ -27,7 +35,7 @@ export const COMMUNITY_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ export class CommunityPageResolver implements Resolve> { constructor( private communityService: CommunityDataService, - private store: Store + private store: Store, ) { } @@ -43,7 +51,7 @@ export class CommunityPageResolver implements Resolve> { route.params.id, true, false, - ...COMMUNITY_PAGE_LINKS_TO_FOLLOW + ...COMMUNITY_PAGE_LINKS_TO_FOLLOW, ).pipe( getFirstCompletedRemoteData(), ); diff --git a/src/app/community-page/create-community-page/create-community-page.component.spec.ts b/src/app/community-page/create-community-page/create-community-page.component.spec.ts index fbff82efd8..08da572a4d 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.spec.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.spec.ts @@ -1,17 +1,22 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouteService } from '../../core/services/route.service'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + import { CommunityDataService } from '../../core/data/community-data.service'; -import { CreateCommunityPageComponent } from './create-community-page.component'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SharedModule } from '../../shared/shared.module'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { CreateCommunityPageComponent } from './create-community-page.component'; describe('CreateCommunityPageComponent', () => { let comp: CreateCommunityPageComponent; @@ -26,9 +31,9 @@ describe('CreateCommunityPageComponent', () => { { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, - { provide: RequestService, useValue: {} } + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/create-community-page/create-community-page.component.ts b/src/app/community-page/create-community-page/create-community-page.component.ts index eea0908388..65ae0abc90 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.ts @@ -1,13 +1,14 @@ import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { RouteService } from '../../core/services/route.service'; import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; +import { RouteService } from '../../core/services/route.service'; +import { Community } from '../../core/shared/community.model'; import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; /** * Component that represents the page where a user can create a new Community @@ -15,7 +16,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-create-community', styleUrls: ['./create-community-page.component.scss'], - templateUrl: './create-community-page.component.html' + templateUrl: './create-community-page.component.html', }) export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; @@ -28,7 +29,7 @@ export class CreateCommunityPageComponent extends CreateComColPageComponent { describe('canActivate', () => { @@ -20,7 +24,7 @@ describe('CreateCommunityPageGuard', () => { } else if (id === 'error-id') { return createFailedRemoteDataObject$('not found', 404); } - } + }, }; router = new RouterMock(); @@ -32,7 +36,7 @@ describe('CreateCommunityPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); @@ -41,7 +45,7 @@ describe('CreateCommunityPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(true) + expect(canActivate).toEqual(true), ); }); @@ -50,7 +54,7 @@ describe('CreateCommunityPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); @@ -59,7 +63,7 @@ describe('CreateCommunityPageGuard', () => { .pipe(first()) .subscribe( (canActivate) => - expect(canActivate).toEqual(false) + expect(canActivate).toEqual(false), ); }); }); diff --git a/src/app/community-page/create-community-page/create-community-page.guard.ts b/src/app/community-page/create-community-page/create-community-page.guard.ts index 835fbb6589..847273b367 100644 --- a/src/app/community-page/create-community-page/create-community-page.guard.ts +++ b/src/app/community-page/create-community-page/create-community-page.guard.ts @@ -1,13 +1,27 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + tap, +} from 'rxjs/operators'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; -import { map, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; /** * Prevent creation of a community with an invalid parent community provided @@ -37,8 +51,8 @@ export class CreateCommunityPageGuard implements CanActivate { if (!isValid) { this.router.navigate(['/404']); } - } - ) - ); + }, + ), + ); } } 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..b241a7027c 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/delete-community-page/delete-community-page.component.spec.ts b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts index 55d0508c10..0892c4d66c 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.spec.ts @@ -1,17 +1,22 @@ import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; +import { RequestService } from '../../core/data/request.service'; +import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { SharedModule } from '../../shared/shared.module'; import { DeleteCommunityPageComponent } from './delete-community-page.component'; -import { RequestService } from '../../core/data/request.service'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { DSONameServiceMock } from '../../shared/mocks/dso-name.service.mock'; describe('DeleteCommunityPageComponent', () => { let comp: DeleteCommunityPageComponent; @@ -26,9 +31,9 @@ describe('DeleteCommunityPageComponent', () => { { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, { provide: NotificationsService, useValue: {} }, - { provide: RequestService, useValue: {}} + { provide: RequestService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.ts b/src/app/community-page/delete-community-page/delete-community-page.component.ts index 65b7c81b38..c6888a893b 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.ts @@ -1,11 +1,15 @@ import { Component } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../core/shared/community.model'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; /** * Component that represents the page where a user can delete an existing Community @@ -13,7 +17,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-delete-community', styleUrls: ['./delete-community-page.component.scss'], - templateUrl: './delete-community-page.component.html' + templateUrl: './delete-community-page.component.html', }) export class DeleteCommunityPageComponent extends DeleteComColPageComponent { protected frontendURL = '/communities/'; diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts index d895cfd820..aa01183519 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts @@ -1,25 +1,71 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + of as observableOf, + of, +} from 'rxjs'; +import { Community } from '../../../core/shared/community.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CommunityAccessControlComponent } from './community-access-control.component'; -xdescribe('CommunityAccessControlComponent', () => { +describe('CommunityAccessControlComponent', () => { let component: CommunityAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }], + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID', + }, + }, + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity), + }), + }, + }, + }; + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CommunityAccessControlComponent ] + declarations: [ CommunityAccessControlComponent ], + providers: [{ provide: ActivatedRoute, useValue: routeStub }], }) - .compileComponents(); + .compileComponents(); }); + beforeEach(() => { fixture = TestBed.createComponent(CommunityAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts index 8a216e38df..e70af7062e 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts @@ -1,10 +1,14 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; import { Community } from '../../../core/shared/community.model'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; @Component({ selector: 'ds-community-access-control', @@ -18,7 +22,7 @@ export class CommunityAccessControlComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.parent.parent.data.pipe( - map((data) => data.dso) + map((data) => data.dso), ).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts index 719cf83a26..c02b390cb2 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.spec.ts @@ -1,15 +1,21 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; - import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { Collection } from '../../../core/shared/collection.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { CommunityAuthorizationsComponent } from './community-authorizations.component'; -import { Collection } from '../../../core/shared/collection.model'; describe('CommunityAuthorizationsComponent', () => { let comp: CommunityAuthorizationsComponent; @@ -19,8 +25,8 @@ describe('CommunityAuthorizationsComponent', () => { uuid: 'community', id: 'community', _links: { - self: { href: 'community-selflink' } - } + self: { href: 'community-selflink' }, + }, }); const communityRD = createSuccessfulRemoteDataObject(community); @@ -29,16 +35,16 @@ describe('CommunityAuthorizationsComponent', () => { parent: { parent: { data: observableOf({ - dso: communityRD - }) - } - } + dso: communityRD, + }), + }, + }, }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - CommonModule + CommonModule, ], declarations: [CommunityAuthorizationsComponent], providers: [ diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts index 7a9f224311..c31f798060 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts @@ -1,7 +1,14 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @@ -25,7 +32,7 @@ export class CommunityAuthorizationsComponent impl * @param {ActivatedRoute} route */ constructor( - private route: ActivatedRoute + private route: ActivatedRoute, ) { } 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/edit-community-page/community-curate/community-curate.component.spec.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts index 1b1ee2c9f9..8928543a3a 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.spec.ts @@ -1,12 +1,20 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; -import { CommunityCurateComponent } from './community-curate.component'; import { Community } from '../../../core/shared/community.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { CommunityCurateComponent } from './community-curate.component'; describe('CommunityCurateComponent', () => { let comp: CommunityCurateComponent; @@ -17,30 +25,30 @@ describe('CommunityCurateComponent', () => { let dsoNameService; const community = Object.assign(new Community(), { - metadata: {'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1'}]} + metadata: { 'dc.title': ['Community Name'], 'dc.identifier.uri': [ { value: '123456789/1' }] }, }); beforeEach(waitForAsync(() => { routeStub = { parent: { data: observableOf({ - dso: createSuccessfulRemoteDataObject(community) - }) - } + dso: createSuccessfulRemoteDataObject(community), + }), + }, }; dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: 'Community Name' + getName: 'Community Name', }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [CommunityCurateComponent], providers: [ - {provide: ActivatedRoute, useValue: routeStub}, - {provide: DSONameService, useValue: dsoNameService} + { provide: ActivatedRoute, useValue: routeStub }, + { provide: DSONameService, useValue: dsoNameService }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); })); @@ -58,7 +66,7 @@ describe('CommunityCurateComponent', () => { }); it('should contain the community information provided in the route', () => { comp.dsoRD$.subscribe((value) => { - expect(value.payload.handle + expect(value.payload.handle, ).toEqual('123456789/1'); }); comp.communityName$.subscribe((value) => { diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts index 8ae04af8f1..aa28644ae7 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.ts @@ -1,10 +1,18 @@ -import { Component, OnInit } from '@angular/core'; -import { Community } from '../../../core/shared/community.model'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { filter, map, take } from 'rxjs/operators'; -import { RemoteData } from '../../../core/data/remote-data'; import { Observable } from 'rxjs'; +import { + filter, + map, + take, +} from 'rxjs/operators'; + import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; import { hasValue } from '../../../shared/empty.util'; /** @@ -35,7 +43,7 @@ export class CommunityCurateComponent implements OnInit { filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { return this.dsoNameService.getName(rd.payload); - }) + }), ); } diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts index c597fac0bd..ec4a502a6e 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts @@ -1,15 +1,20 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared/shared.module'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute } from '@angular/router'; -import { of as observableOf } from 'rxjs'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { CommunityMetadataComponent } from './community-metadata.component'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + import { CommunityDataService } from '../../../core/data/community-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SharedModule } from '../../../shared/shared.module'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { CommunityMetadataComponent } from './community-metadata.component'; describe('CommunityMetadataComponent', () => { let comp: CommunityMetadataComponent; @@ -22,9 +27,9 @@ describe('CommunityMetadataComponent', () => { providers: [ { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() } + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts index a2dbfa6eb6..9a6b93a0ce 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts @@ -1,11 +1,15 @@ import { Component } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../core/shared/community.model'; -import { CommunityDataService } from '../../../core/data/community-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { Community } from '../../../core/shared/community.model'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; + /** * Component for editing a community's metadata */ @@ -22,7 +26,7 @@ export class CommunityMetadataComponent extends ComcolMetadataComponent { @@ -39,10 +49,10 @@ describe('CommunityRolesComponent', () => { href: 'adminGroup link', }, }, - }) + }), ), - }) - } + }), + }, }; const requestService = { @@ -59,7 +69,7 @@ describe('CommunityRolesComponent', () => { SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), - NoopAnimationsModule + NoopAnimationsModule, ], declarations: [ CommunityRolesComponent, @@ -69,9 +79,9 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, - { provide: NotificationsService, useClass: NotificationsServiceStub } + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(CommunityRolesComponent); diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts index 9468aa7048..5f6c57defc 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts @@ -1,11 +1,21 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { Community } from '../../../core/shared/community.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { + first, + map, +} from 'rxjs/operators'; + import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; import { HALLink } from '../../../core/shared/hal-link.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; /** * Component for managing a community's roles diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts index 3a4c3351c3..824aef3ba4 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.spec.ts @@ -1,13 +1,18 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { SharedModule } from '../../shared/shared.module'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { EditCommunityPageComponent } from './edit-community-page.component'; + import { CommunityDataService } from '../../core/data/community-data.service'; +import { SharedModule } from '../../shared/shared.module'; +import { EditCommunityPageComponent } from './edit-community-page.component'; describe('EditCommunityPageComponent', () => { let comp: EditCommunityPageComponent; @@ -15,25 +20,25 @@ describe('EditCommunityPageComponent', () => { const routeStub = { data: observableOf({ - dso: { payload: {} } + dso: { payload: {} }, }), routeConfig: { children: [ { path: 'mockUrl', data: { - hideReturnButton: false - } - } - ] + hideReturnButton: false, + }, + }, + ], }, snapshot: { firstChild: { routeConfig: { - path: 'mockUrl' - } - } - } + path: 'mockUrl', + }, + }, + }, }; beforeEach(waitForAsync(() => { @@ -44,7 +49,7 @@ describe('EditCommunityPageComponent', () => { { provide: CommunityDataService, useValue: {} }, { provide: ActivatedRoute, useValue: routeStub }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.ts b/src/app/community-page/edit-community-page/edit-community-page.component.ts index 54a6ee4944..5bd31e9ba0 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.ts @@ -1,6 +1,10 @@ import { Component } from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; + import { Community } from '../../core/shared/community.model'; -import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCommunityPageRoute } from '../community-page-routing-paths'; @@ -9,14 +13,14 @@ import { getCommunityPageRoute } from '../community-page-routing-paths'; */ @Component({ selector: 'ds-edit-community', - templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html', }) export class EditCommunityPageComponent extends EditComColPageComponent { type = 'community'; public constructor( protected router: Router, - protected route: ActivatedRoute + protected route: ActivatedRoute, ) { super(router, route); } diff --git a/src/app/community-page/edit-community-page/edit-community-page.module.ts b/src/app/community-page/edit-community-page/edit-community-page.module.ts index 5190d6a008..13555e3800 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.module.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.module.ts @@ -1,19 +1,18 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { AccessControlFormModule } from '../../shared/access-control-form-container/access-control-form.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { SharedModule } from '../../shared/shared.module'; -import { EditCommunityPageRoutingModule } from './edit-community-page.routing.module'; -import { EditCommunityPageComponent } from './edit-community-page.component'; +import { CommunityFormModule } from '../community-form/community-form.module'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; import { CommunityCurateComponent } from './community-curate/community-curate.component'; import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { CommunityFormModule } from '../community-form/community-form.module'; -import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; -import { ComcolModule } from '../../shared/comcol/comcol.module'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; -import { - AccessControlFormModule -} from '../../shared/access-control-form-container/access-control-form.module'; +import { EditCommunityPageComponent } from './edit-community-page.component'; +import { EditCommunityPageRoutingModule } from './edit-community-page.routing.module'; /** * Module that contains all components related to the Edit Community page administrator functionality @@ -34,8 +33,8 @@ import { CommunityMetadataComponent, CommunityRolesComponent, CommunityAuthorizationsComponent, - CommunityAccessControlComponent - ] + CommunityAccessControlComponent, + ], }) export class EditCommunityPageModule { diff --git a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts index 994c6b5e96..4b0fa5d70b 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts @@ -1,17 +1,18 @@ -import { EditCommunityPageComponent } from './edit-community-page.component'; -import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; +import { CommunityCurateComponent } from './community-curate/community-curate.component'; import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; import { CommunityRolesComponent } from './community-roles/community-roles.component'; -import { CommunityCurateComponent } from './community-curate/community-curate.component'; -import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; -import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; -import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; -import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; -import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; -import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; -import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; -import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { EditCommunityPageComponent } from './edit-community-page.component'; /** * Routing module that handles the routing for the Edit Community page administrator functionality @@ -22,7 +23,7 @@ import { CommunityAccessControlComponent } from './community-access-control/comm { path: '', resolve: { - breadcrumb: I18nBreadcrumbResolver + breadcrumb: I18nBreadcrumbResolver, }, data: { breadcrumbKey: 'community.edit' }, component: EditCommunityPageComponent, @@ -31,7 +32,7 @@ import { CommunityAccessControlComponent } from './community-access-control/comm { path: '', redirectTo: 'metadata', - pathMatch: 'full' + pathMatch: 'full', }, { path: 'metadata', @@ -39,23 +40,23 @@ import { CommunityAccessControlComponent } from './community-access-control/comm data: { title: 'community.edit.tabs.metadata.title', hideReturnButton: true, - showBreadcrumbs: true - } + showBreadcrumbs: true, + }, }, { path: 'roles', component: CommunityRolesComponent, - data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true } + data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true }, }, { path: 'curate', component: CommunityCurateComponent, - data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true } + data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true }, }, { path: 'access-control', component: CommunityAccessControlComponent, - data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true }, }, /*{ path: 'authorizations', @@ -69,34 +70,34 @@ import { CommunityAccessControlComponent } from './community-access-control/comm { path: 'create', resolve: { - resourcePolicyTarget: ResourcePolicyTargetResolver + resourcePolicyTarget: ResourcePolicyTargetResolver, }, component: ResourcePolicyCreateComponent, - data: { title: 'resource-policies.create.page.title' } + data: { title: 'resource-policies.create.page.title' }, }, { path: 'edit', resolve: { - resourcePolicy: ResourcePolicyResolver + resourcePolicy: ResourcePolicyResolver, }, component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title' } + data: { title: 'resource-policies.edit.page.title' }, }, { path: '', component: CommunityAuthorizationsComponent, - data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true } - } - ] - } - ] - } - ]) + data: { title: 'community.edit.tabs.authorizations.title', showBreadcrumbs: true, hideReturnButton: true }, + }, + ], + }, + ], + }, + ]), ], providers: [ ResourcePolicyResolver, - ResourcePolicyTargetResolver - ] + ResourcePolicyTargetResolver, + ], }) export class EditCommunityPageRoutingModule { diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html similarity index 90% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html index 69f16ee3ac..b5fbf1a01d 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-collection-list.head' | translate}}

+

{{'community.sub-collection-list.head' | translate}}

{ + let comp: CommunityPageSubCollectionListComponent; + let fixture: ComponentFixture; + let collectionDataServiceStub: any; + let themeService; + let subCollList = []; + + const collections = [Object.assign(new Community(), { + id: '123456789-1', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-4', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Collection 7' }, + ], + }, + }), + ]; + + const mockCommunity = Object.assign(new Community(), { + id: '123456789', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' }, + ], + }, + }); + + collectionDataServiceStub = { + findByParent(parentUUID: string, options: FindListOptions = {}) { + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1; + } + elementsPerPage = 5; + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > subCollList.length) { + endPageIndex = subCollList.length; + } + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCollList.slice(startPageIndex, endPageIndex))); + + }, + }; + + const paginationService = new PaginationServiceStub(); + + themeService = getMockThemeService(); + + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '', + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test', + ], + })), + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + SharedModule, + RouterTestingModule.withRoutes([]), + NgbModule, + NoopAnimationsModule, + ], + declarations: [CommunityPageSubCollectionListComponent], + providers: [ + { provide: CollectionDataService, useValue: collectionDataServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { provide: PaginationService, useValue: paginationService }, + { provide: SelectableListService, useValue: {} }, + { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityPageSubCollectionListComponent); + comp = fixture.componentInstance; + comp.community = mockCommunity; + }); + + + it('should display a list of collections', async () => { + subCollList = collections; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const collList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(collList.length).toEqual(5); + expect(collList[0].nativeElement.textContent).toContain('Collection 1'); + expect(collList[1].nativeElement.textContent).toContain('Collection 2'); + expect(collList[2].nativeElement.textContent).toContain('Collection 3'); + expect(collList[3].nativeElement.textContent).toContain('Collection 4'); + expect(collList[4].nativeElement.textContent).toContain('Collection 5'); + }); + + it('should not display the header when list of collections is empty', () => { + subCollList = []; + fixture.detectChanges(); + + const subComHead = fixture.debugElement.queryAll(By.css('h2')); + expect(subComHead.length).toEqual(0); + }); +}); diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts similarity index 58% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts index 3a77149e5b..f526f76641 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,25 +1,36 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; - -import { RemoteData } from '../../core/data/remote-data'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CollectionDataService } from '../../core/data/collection-data.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; + +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { hasValue } from '../../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; @Component({ selector: 'ds-community-page-sub-collection-list', styleUrls: ['./community-page-sub-collection-list.component.scss'], templateUrl: './community-page-sub-collection-list.component.html', - animations:[fadeIn] + animations:[fadeIn], }) export class CommunityPageSubCollectionListComponent implements OnInit, OnDestroy { @Input() community: Community; @@ -50,6 +61,8 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro */ subCollectionsRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CollectionDataService, protected paginationService: PaginationService, @@ -74,24 +87,25 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro * Initialise the list of collections */ initPage() { - const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); - const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); + 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, elementsPerPage: currentPagination.pageSize, - sort: {field: currentSort.field, direction: currentSort.direction} + sort: { field: currentSort.field, direction: currentSort.direction }, }); - }) + }), ).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-collection-list/themed-community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts similarity index 64% rename from src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts index f1f49f204c..edc15260f6 100644 --- a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts @@ -1,12 +1,16 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; @Component({ selector: 'ds-themed-community-page-sub-collection-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', }) export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent { @Input() community: Community; @@ -18,7 +22,7 @@ export class ThemedCollectionPageSubCollectionListComponent extends ThemedCompon } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-collection-list/community-page-sub-collection-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component`); } protected importUnthemedComponent(): Promise { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html new file mode 100644 index 0000000000..515e08ffdf --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts new file mode 100644 index 0000000000..e28ff98ee7 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts @@ -0,0 +1,37 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { SubComColSectionComponent } from './sub-com-col-section.component'; + +describe('SubComColSectionComponent', () => { + let component: SubComColSectionComponent; + let fixture: ComponentFixture; + + let activatedRoute: ActivatedRouteStub; + + beforeEach(async () => { + activatedRoute = new ActivatedRouteStub(); + activatedRoute.parent = new ActivatedRouteStub(); + + await TestBed.configureTestingModule({ + declarations: [ + SubComColSectionComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubComColSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts new file mode 100644 index 0000000000..eb6f827240 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts @@ -0,0 +1,35 @@ +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Data, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; + +@Component({ + selector: 'ds-sub-com-col-section', + templateUrl: './sub-com-col-section.component.html', + styleUrls: ['./sub-com-col-section.component.scss'], +}) +export class SubComColSectionComponent implements OnInit { + + community$: Observable; + + constructor( + private route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.community$ = this.route.parent.data.pipe( + map((data: Data) => (data.dso as RemoteData).payload), + ); + } + +} diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html similarity index 90% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html index be2788a9f4..0834d08ba5 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-community-list.head' | translate}}

+

{{'community.sub-community-list.head' | translate}}

{ + let comp: CommunityPageSubCommunityListComponent; + let fixture: ComponentFixture; + let communityDataServiceStub: any; + let themeService; + let subCommList = []; + + const subcommunities = [Object.assign(new Community(), { + id: '123456789-1', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 1' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-2', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 2' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-3', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 3' }, + ], + }, + }), + Object.assign(new Community(), { + id: '12345678942', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 4' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-5', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 5' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-6', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 6' }, + ], + }, + }), + Object.assign(new Community(), { + id: '123456789-7', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'SubCommunity 7' }, + ], + }, + }), + ]; + + const mockCommunity = Object.assign(new Community(), { + id: '123456789', + metadata: { + 'dc.title': [ + { language: 'en_US', value: 'Test title' }, + ], + }, + }); + + communityDataServiceStub = { + findByParent(parentUUID: string, options: FindListOptions = {}) { + let currentPage = options.currentPage; + let elementsPerPage = options.elementsPerPage; + if (currentPage === undefined) { + currentPage = 1; + } + elementsPerPage = 5; + + const startPageIndex = (currentPage - 1) * elementsPerPage; + let endPageIndex = (currentPage * elementsPerPage); + if (endPageIndex > subCommList.length) { + endPageIndex = subCommList.length; + } + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCommList.slice(startPageIndex, endPageIndex))); + + }, + }; + + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '', + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test', + ], + })), + }); + + const paginationService = new PaginationServiceStub(); + + themeService = getMockThemeService(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + SharedModule, + RouterTestingModule.withRoutes([]), + NgbModule, + NoopAnimationsModule, + ], + declarations: [CommunityPageSubCommunityListComponent], + providers: [ + { provide: CommunityDataService, useValue: communityDataServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { provide: PaginationService, useValue: paginationService }, + { provide: SelectableListService, useValue: {} }, + { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent); + comp = fixture.componentInstance; + comp.community = mockCommunity; + + }); + + + it('should display a list of sub-communities', async () => { + subCommList = subcommunities; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const subComList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); + }); + + it('should not display the header when list of sub-communities is empty', () => { + subCommList = []; + fixture.detectChanges(); + + const subComHead = fixture.debugElement.queryAll(By.css('h2')); + expect(subComHead.length).toEqual(0); + }); +}); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts similarity index 67% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts index 5a0409a051..181e6a3bd4 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts @@ -1,24 +1,35 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; - -import { RemoteData } from '../../core/data/remote-data'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Subscription, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { hasValue } from '../../shared/empty.util'; + +import { + SortDirection, + SortOptions, +} from '../../../../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { hasValue } from '../../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; @Component({ selector: 'ds-community-page-sub-community-list', styleUrls: ['./community-page-sub-community-list.component.scss'], templateUrl: './community-page-sub-community-list.component.html', - animations: [fadeIn] + animations: [fadeIn], }) /** * Component to render the sub-communities of a Community @@ -52,6 +63,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy */ subCommunitiesRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CommunityDataService, protected paginationService: PaginationService, @@ -79,21 +92,22 @@ 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, elementsPerPage: currentPagination.pageSize, - sort: { field: currentSort.field, direction: currentSort.direction } + sort: { field: currentSort.field, direction: currentSort.direction }, }); - }) + }), ).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/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts similarity index 64% rename from src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts index 852c53186e..8de902138e 100644 --- a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts @@ -1,12 +1,16 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { + Component, + Input, +} from '@angular/core'; + +import { Community } from '../../../../core/shared/community.model'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; @Component({ selector: 'ds-themed-community-page-sub-community-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', }) export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponent { @@ -19,7 +23,7 @@ export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponen } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-community-list/community-page-sub-community-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component`); } protected importUnthemedComponent(): Promise { diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts deleted file mode 100644 index bca3c42a95..0000000000 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; -import { Community } from '../../core/shared/community.model'; -import { SharedModule } from '../../shared/shared.module'; -import { CollectionDataService } from '../../core/data/collection-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { buildPaginatedList } from '../../core/data/paginated-list.model'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { HostWindowService } from '../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; -import { GroupDataService } from '../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ConfigurationDataService } from '../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; - -describe('CommunityPageSubCollectionList Component', () => { - let comp: CommunityPageSubCollectionListComponent; - let fixture: ComponentFixture; - let collectionDataServiceStub: any; - let themeService; - let subCollList = []; - - const collections = [Object.assign(new Community(), { - id: '123456789-1', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 1' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 3' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-4', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Collection 7' } - ] - } - }) - ]; - - const mockCommunity = Object.assign(new Community(), { - id: '123456789', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } - }); - - collectionDataServiceStub = { - findByParent(parentUUID: string, options: FindListOptions = {}) { - let currentPage = options.currentPage; - let elementsPerPage = options.elementsPerPage; - if (currentPage === undefined) { - currentPage = 1; - } - elementsPerPage = 5; - const startPageIndex = (currentPage - 1) * elementsPerPage; - let endPageIndex = (currentPage * elementsPerPage); - if (endPageIndex > subCollList.length) { - endPageIndex = subCollList.length; - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCollList.slice(startPageIndex, endPageIndex))); - - } - }; - - const paginationService = new PaginationServiceStub(); - - themeService = getMockThemeService(); - - const linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '' - }); - - const groupDataService = jasmine.createSpyObj('groupsDataService', { - findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), - getGroupRegistryRouterLink: '', - getUUIDFromString: '', - }); - - const configurationDataService = jasmine.createSpyObj('configurationDataService', { - findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { - name: 'test', - values: [ - 'org.dspace.ctask.general.ProfileFormats = test' - ] - })) - }); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - SharedModule, - RouterTestingModule.withRoutes([]), - NgbModule, - NoopAnimationsModule - ], - declarations: [CommunityPageSubCollectionListComponent], - providers: [ - { provide: CollectionDataService, useValue: collectionDataServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { provide: PaginationService, useValue: paginationService }, - { provide: SelectableListService, useValue: {} }, - { provide: ThemeService, useValue: themeService }, - { provide: GroupDataService, useValue: groupDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: ConfigurationDataService, useValue: configurationDataService }, - { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, - ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CommunityPageSubCollectionListComponent); - comp = fixture.componentInstance; - comp.community = mockCommunity; - }); - - - it('should display a list of collections', () => { - waitForAsync(() => { - subCollList = collections; - fixture.detectChanges(); - - const collList = fixture.debugElement.queryAll(By.css('li')); - expect(collList.length).toEqual(5); - expect(collList[0].nativeElement.textContent).toContain('Collection 1'); - expect(collList[1].nativeElement.textContent).toContain('Collection 2'); - expect(collList[2].nativeElement.textContent).toContain('Collection 3'); - expect(collList[3].nativeElement.textContent).toContain('Collection 4'); - expect(collList[4].nativeElement.textContent).toContain('Collection 5'); - }); - }); - - it('should not display the header when list of collections is empty', () => { - subCollList = []; - fixture.detectChanges(); - - const subComHead = fixture.debugElement.queryAll(By.css('h2')); - expect(subComHead.length).toEqual(0); - }); -}); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts deleted file mode 100644 index 0a14fe6dd1..0000000000 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RouterTestingModule } from '@angular/router/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { By } from '@angular/platform-browser'; - -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - -import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; -import { Community } from '../../core/shared/community.model'; -import { buildPaginatedList } from '../../core/data/paginated-list.model'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { SharedModule } from '../../shared/shared.module'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { HostWindowService } from '../../shared/host-window.service'; -import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; -import { CommunityDataService } from '../../core/data/community-data.service'; -import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; -import { ThemeService } from '../../shared/theme-support/theme.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; -import { GroupDataService } from '../../core/eperson/group-data.service'; -import { LinkHeadService } from '../../core/services/link-head.service'; -import { ConfigurationDataService } from '../../core/data/configuration-data.service'; -import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; -import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; -import { createPaginatedList } from '../../shared/testing/utils.test'; - -describe('CommunityPageSubCommunityListComponent Component', () => { - let comp: CommunityPageSubCommunityListComponent; - let fixture: ComponentFixture; - let communityDataServiceStub: any; - let themeService; - let subCommList = []; - - const subcommunities = [Object.assign(new Community(), { - id: '123456789-1', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 1' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-2', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 2' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-3', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 3' } - ] - } - }), - Object.assign(new Community(), { - id: '12345678942', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 4' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-5', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 5' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-6', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 6' } - ] - } - }), - Object.assign(new Community(), { - id: '123456789-7', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'SubCommunity 7' } - ] - } - }) - ]; - - const mockCommunity = Object.assign(new Community(), { - id: '123456789', - metadata: { - 'dc.title': [ - { language: 'en_US', value: 'Test title' } - ] - } - }); - - communityDataServiceStub = { - findByParent(parentUUID: string, options: FindListOptions = {}) { - let currentPage = options.currentPage; - let elementsPerPage = options.elementsPerPage; - if (currentPage === undefined) { - currentPage = 1; - } - elementsPerPage = 5; - - const startPageIndex = (currentPage - 1) * elementsPerPage; - let endPageIndex = (currentPage * elementsPerPage); - if (endPageIndex > subCommList.length) { - endPageIndex = subCommList.length; - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), subCommList.slice(startPageIndex, endPageIndex))); - - } - }; - - const linkHeadService = jasmine.createSpyObj('linkHeadService', { - addTag: '' - }); - - const groupDataService = jasmine.createSpyObj('groupsDataService', { - findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), - getGroupRegistryRouterLink: '', - getUUIDFromString: '', - }); - - const configurationDataService = jasmine.createSpyObj('configurationDataService', { - findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { - name: 'test', - values: [ - 'org.dspace.ctask.general.ProfileFormats = test' - ] - })) - }); - - const paginationService = new PaginationServiceStub(); - - themeService = getMockThemeService(); - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - SharedModule, - RouterTestingModule.withRoutes([]), - NgbModule, - NoopAnimationsModule - ], - declarations: [CommunityPageSubCommunityListComponent], - providers: [ - { provide: CommunityDataService, useValue: communityDataServiceStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { provide: PaginationService, useValue: paginationService }, - { provide: SelectableListService, useValue: {} }, - { provide: ThemeService, useValue: themeService }, - { provide: GroupDataService, useValue: groupDataService }, - { provide: LinkHeadService, useValue: linkHeadService }, - { provide: ConfigurationDataService, useValue: configurationDataService }, - { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, - ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CommunityPageSubCommunityListComponent); - comp = fixture.componentInstance; - comp.community = mockCommunity; - - }); - - - it('should display a list of sub-communities', () => { - waitForAsync(() => { - subCommList = subcommunities; - fixture.detectChanges(); - - const subComList = fixture.debugElement.queryAll(By.css('li')); - expect(subComList.length).toEqual(5); - expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); - expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); - expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); - expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); - expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); - }); - }); - - it('should not display the header when list of sub-communities is empty', () => { - subCommList = []; - fixture.detectChanges(); - - const subComHead = fixture.debugElement.queryAll(By.css('h2')); - expect(subComHead.length).toEqual(0); - }); -}); diff --git a/src/app/community-page/themed-community-page.component.ts b/src/app/community-page/themed-community-page.component.ts index eeb058fb04..8b200b768e 100644 --- a/src/app/community-page/themed-community-page.component.ts +++ b/src/app/community-page/themed-community-page.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; + import { ThemedComponent } from '../shared/theme-support/themed.component'; import { CommunityPageComponent } from './community-page.component'; diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts index 3747edd532..8cc071ec8e 100644 --- a/src/app/core/auth/auth-blocking.guard.spec.ts +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -1,12 +1,23 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { Store, StoreModule } from '@ngrx/store'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { cold } from 'jasmine-marbles'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { AuthBlockingGuard } from './auth-blocking.guard'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; import { authReducer } from './auth.reducer'; +import { AuthBlockingGuard } from './auth-blocking.guard'; describe('AuthBlockingGuard', () => { let guard: AuthBlockingGuard; @@ -21,9 +32,9 @@ describe('AuthBlockingGuard', () => { loaded: false, blocking: undefined, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; beforeEach(waitForAsync(() => { @@ -33,8 +44,8 @@ describe('AuthBlockingGuard', () => { ], providers: [ provideMockStore({ initialState }), - { provide: AuthBlockingGuard, useValue: guard } - ] + { provide: AuthBlockingGuard, useValue: guard }, + ], }).compileComponents(); })); @@ -58,9 +69,9 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: true - } - }) + blocking: true, + }, + }), }); mockStore.setState(state); }); @@ -76,9 +87,9 @@ describe('AuthBlockingGuard', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'auth': { - blocking: false - } - }) + blocking: false, + }, + }), }); mockStore.setState(state); }); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts index 9054f66f8b..b6c575d789 100644 --- a/src/app/core/auth/auth-blocking.guard.ts +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -1,8 +1,17 @@ import { Injectable } from '@angular/core'; import { CanActivate } from '@angular/router'; -import { select, Store } from '@ngrx/store'; +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, + take, +} from 'rxjs/operators'; + import { AppState } from '../../app.reducer'; import { isAuthenticationBlocking } from './selectors'; @@ -12,7 +21,7 @@ import { isAuthenticationBlocking } from './selectors'; * To ensure all rest requests get the correct auth header. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class AuthBlockingGuard implements CanActivate { diff --git a/src/app/core/auth/auth-request.service.spec.ts b/src/app/core/auth/auth-request.service.spec.ts index 063aad612f..55101dd3c5 100644 --- a/src/app/core/auth/auth-request.service.spec.ts +++ b/src/app/core/auth/auth-request.service.spec.ts @@ -1,17 +1,22 @@ -import { AuthRequestService } from './auth-request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { PostRequest } from '../data/request.models'; import { TestScheduler } from 'rxjs/testing'; + import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { ShortLivedToken } from './models/short-lived-token.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; +import { ShortLivedToken } from './models/short-lived-token.model'; import objectContaining = jasmine.objectContaining; -import { AuthStatus } from './models/auth-status.model'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { RestRequestMethod } from '../data/rest-request-method'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthStatus } from './models/auth-status.model'; describe(`AuthRequestService`, () => { let halService: HALEndpointService; @@ -30,7 +35,7 @@ describe(`AuthRequestService`, () => { constructor( hes: HALEndpointService, rs: RequestService, - rdbs: RemoteDataBuildService + rdbs: RemoteDataBuildService, ) { super(hes, rs, rdbs); } @@ -44,19 +49,19 @@ describe(`AuthRequestService`, () => { endpointURL = 'https://rest.api/auth'; requestID = 'requestID'; shortLivedToken = Object.assign(new ShortLivedToken(), { - value: 'some-token' + value: 'some-token', }); shortLivedTokenRD = createSuccessfulRemoteDataObject(shortLivedToken); halService = jasmine.createSpyObj('halService', { - 'getEndpoint': cold('a', { a: endpointURL }) + 'getEndpoint': cold('a', { a: endpointURL }), }); requestService = jasmine.createSpyObj('requestService', { 'generateRequestId': requestID, 'send': null, }); rdbService = jasmine.createSpyObj('rdbService', { - 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) + 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }), }); service = new TestAuthRequestService(halService, requestService, rdbService); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 7c1f17dec2..5d11b9f4cb 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,18 +1,29 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../shared/empty.util'; -import { GetRequest, PostRequest, } from '../data/request.models'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { + GetRequest, + PostRequest, +} from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequest } from '../data/rest-request.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { RestRequest } from '../data/rest-request.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Abstract service to send authentication requests @@ -23,8 +34,8 @@ export abstract class AuthRequestService { constructor(protected halService: HALEndpointService, protected requestService: RequestService, - private rdbService: RemoteDataBuildService - ) { + private rdbService: RemoteDataBuildService, + ) { } /** @@ -65,7 +76,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)), - take(1) + take(1), ).subscribe((request: PostRequest) => { this.requestService.send(request); }); @@ -90,7 +101,7 @@ export abstract class AuthRequestService { map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)), - take(1) + take(1), ).subscribe((request: GetRequest) => { this.requestService.send(request); }); @@ -125,7 +136,7 @@ export abstract class AuthRequestService { } else { return null; } - }) + }), ); } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 6bc4565682..03b6bc1191 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,12 +1,13 @@ /* eslint-disable max-classes-per-file */ // import @ngrx import { Action } from '@ngrx/store'; + // import type function import { type } from '../../shared/ngrx/type'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -38,7 +39,7 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'), SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'), - UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') + UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE'), }; @@ -426,6 +427,15 @@ export class UnsetUserAsIdleAction implements Action { public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; } +/** + * Authentication error actions that include Error payloads. + */ +export type AuthErrorActionsWithErrorPayload + = AuthenticatedErrorAction + | AuthenticationErrorAction + | LogOutErrorAction + | RetrieveAuthenticatedEpersonErrorAction; + /** * Actions type. * @type {AuthActions} @@ -433,9 +443,7 @@ export class UnsetUserAsIdleAction implements Action { export type AuthActions = AuthenticateAction | AuthenticatedAction - | AuthenticatedErrorAction | AuthenticatedSuccessAction - | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenCookieAction @@ -452,10 +460,9 @@ export type AuthActions | RetrieveAuthMethodsErrorAction | RetrieveTokenAction | RetrieveAuthenticatedEpersonAction - | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction | SetRedirectUrlAction | RedirectAfterLoginSuccessAction | SetUserAsIdleAction - | UnsetUserAsIdleAction; - + | UnsetUserAsIdleAction + | AuthErrorActionsWithErrorPayload; diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 2e6ba917aa..a423455594 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,12 +1,38 @@ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; - +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, + throwError as observableThrow, +} from 'rxjs'; -import { AuthEffects } from './auth.effects'; +import { + AppState, + storeModuleConfig, +} from '../../app.reducer'; +import { + authMethodsMock, + AuthServiceStub, +} from '../../shared/testing/auth-service.stub'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { AuthActionTypes, AuthenticatedAction, @@ -25,17 +51,16 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, - RetrieveTokenAction + RetrieveTokenAction, } from './auth.actions'; -import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub'; -import { AuthService } from './auth.service'; +import { AuthEffects } from './auth.effects'; import { authReducer } from './auth.reducer'; +import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AppState, storeModuleConfig } from '../../app.reducer'; -import { StoreActionTypes } from '../../store.actions'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -56,9 +81,9 @@ describe('AuthEffects', () => { authenticated: false, loaded: false, loading: false, - authMethods: [] - } - } + authMethods: [], + }, + }, }; } @@ -66,7 +91,7 @@ describe('AuthEffects', () => { init(); TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({ auth: authReducer }, storeModuleConfig) + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig), ], providers: [ AuthEffects, @@ -88,8 +113,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'password' } - } + payload: { email: 'user', password: 'password' }, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -105,8 +130,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE, - payload: { email: 'user', password: 'wrongpassword' } - } + payload: { email: 'user', password: 'wrongpassword' }, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); @@ -161,9 +186,9 @@ describe('AuthEffects', () => { type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: { authenticated: true, authToken: token, - userHref: EPersonMock._links.self.href - } - } + userHref: EPersonMock._links.self.href, + }, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) }); @@ -211,8 +236,8 @@ describe('AuthEffects', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( { - authenticated: true - }) + authenticated: true, + }), ); spyOn((authEffects as any).authService, 'setExternalAuthStatus'); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -230,7 +255,7 @@ describe('AuthEffects', () => { it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( observableOf( - { authenticated: false }) + { authenticated: false }), ); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); @@ -260,8 +285,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, - payload: EPersonMock._links.self.href - } + payload: EPersonMock._links.self.href, + }, }); const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id) }); @@ -314,8 +339,8 @@ describe('AuthEffects', () => { it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); @@ -330,8 +355,8 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { - type: AuthActionTypes.RETRIEVE_TOKEN - } + type: AuthActionTypes.RETRIEVE_TOKEN, + }, }); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 281355b769..2919a40fa8 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,27 +1,47 @@ -import { Injectable, NgZone } from '@angular/core'; - +import { + Injectable, + NgZone, + Type, +} from '@angular/core'; +// import @ngrx +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + select, + Store, +} from '@ngrx/store'; import { asyncScheduler, combineLatest as observableCombineLatest, Observable, of as observableOf, queueScheduler, - timer + timer, } from 'rxjs'; -import { catchError, filter, map, observeOn, switchMap, take, tap } from 'rxjs/operators'; -// import @ngrx -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Action, select, Store } from '@ngrx/store'; +import { + catchError, + filter, + map, + observeOn, + switchMap, + take, + tap, +} from 'rxjs/operators'; -// import services -import { AuthService } from './auth.service'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; import { AppState } from '../../app.reducer'; -import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; import { StoreActionTypes } from '../../store.actions'; -import { AuthMethod } from './models/auth.method'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { RequestActionTypes } from '../data/request.actions'; +import { EPerson } from '../eperson/models/eperson.model'; +import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; +import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; // import actions import { AuthActionTypes, @@ -31,6 +51,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, + AuthErrorActionsWithErrorPayload, CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, @@ -45,20 +66,32 @@ import { RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, RetrieveTokenAction, - SetUserAsIdleAction + SetUserAsIdleAction, } from './auth.actions'; -import { hasValue } from '../../shared/empty.util'; -import { environment } from '../../../environments/environment'; -import { RequestActionTypes } from '../data/request.actions'; -import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; -import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; -import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; -import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +// import services +import { AuthService } from './auth.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { + isAuthenticated, + isAuthenticatedLoaded, +} from './selectors'; // Action Types that do not break/prevent the user from an idle state const IDLE_TIMER_IGNORE_TYPES: string[] = [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE), - ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; + +export function errorToAuthAction$(actionType: Type, error: unknown): Observable { + if (error instanceof Error) { + return observableOf(new actionType(error)); + } + + // If we caught something that's not an Error: complain & drop type safety + console.warn('AuthEffects caught non-Error object:', error); + return observableOf(new actionType(error as any)); +} @Injectable() export class AuthEffects { @@ -73,14 +106,14 @@ export class AuthEffects { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( take(1), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)), )); public authenticated$: Observable = createEffect(() => this.actions$.pipe( @@ -88,8 +121,9 @@ export class AuthEffects { switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), + ); + }), )); public authenticatedSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -97,7 +131,7 @@ export class AuthEffects { tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( take(1), - map((redirectUrl: string) => [action, redirectUrl]) + map((redirectUrl: string) => [action, redirectUrl]), )), map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => { if (hasValue(redirectUrl)) { @@ -105,7 +139,7 @@ export class AuthEffects { } else { return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); } - }) + }), )); public redirectAfterLoginSuccess$: Observable = createEffect(() => this.actions$.pipe( @@ -113,13 +147,13 @@ export class AuthEffects { tap((action: RedirectAfterLoginSuccessAction) => { this.authService.clearRedirectUrl(); this.authService.navigateToRedirectUrl(action.payload); - }) + }), ), { dispatch: false }); // It means "reacts to this action but don't send another" public authenticatedError$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) + tap((action: LogOutSuccessAction) => this.authService.removeToken()), ), { dispatch: false }); public retrieveAuthenticatedEperson$: Observable = createEffect(() => this.actions$.pipe( @@ -134,17 +168,18 @@ export class AuthEffects { } return user$.pipe( map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), - catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); - }) + catchError((error: unknown) => errorToAuthAction$(RetrieveAuthenticatedEpersonErrorAction, error)), + ); + }), )); public checkToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { return this.authService.hasValidAuthenticationToken().pipe( map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + catchError((error: unknown) => observableOf(new CheckAuthenticationTokenCookieAction())), ); - }) + }), )); public checkTokenCookie$: Observable = createEffect(() => this.actions$.pipe( @@ -160,9 +195,9 @@ export class AuthEffects { return new RetrieveAuthMethodsAction(response); } }), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), ); - }) + }), )); public retrieveToken$: Observable = createEffect(() => this.actions$.pipe( @@ -171,24 +206,24 @@ export class AuthEffects { return this.authService.refreshAuthenticationToken(null).pipe( take(1), map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) + catchError((error: unknown) => errorToAuthAction$(AuthenticationErrorAction, error)), ); - }) + }), )); public refreshToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), switchMap((action: RefreshTokenAction) => { return this.authService.refreshAuthenticationToken(action.payload).pipe( map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) + catchError((error: unknown) => observableOf(new RefreshTokenErrorAction())), ); - }) + }), )); // It means "reacts to this action but don't send another" public refreshTokenSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)), ), { dispatch: false }); /** @@ -204,7 +239,7 @@ export class AuthEffects { take(1), filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), - tap(() => this.authService.resetAuthenticationError()) + tap(() => this.authService.resetAuthenticationError()), ); })), { dispatch: false }); @@ -213,9 +248,9 @@ export class AuthEffects { * authorizations endpoint, to be sure to have consistent responses after a login with external idp * */ - invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ + invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()) + tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()), ), { dispatch: false }); public logOut$: Observable = createEffect(() => this.actions$ @@ -224,24 +259,24 @@ export class AuthEffects { switchMap(() => { this.authService.stopImpersonating(); return this.authService.logout().pipe( - map((value) => new LogOutSuccessAction()), - catchError((error) => observableOf(new LogOutErrorAction(error))) + map(() => new LogOutSuccessAction()), + catchError((error: unknown) => errorToAuthAction$(LogOutErrorAction, error)), ); - }) + }), )); public logOutSuccess$: Observable = createEffect(() => this.actions$ .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), tap(() => this.authService.removeToken()), tap(() => this.authService.clearRedirectUrl()), - tap(() => this.authService.refreshAfterLogout()) + tap(() => this.authService.refreshAfterLogout()), ), { dispatch: false }); public redirectToLoginTokenExpired$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLoginWhenTokenExpired()) + tap(() => this.authService.redirectToLoginWhenTokenExpired()), ), { dispatch: false }); public retrieveMethods$: Observable = createEffect(() => this.actions$ @@ -251,9 +286,9 @@ export class AuthEffects { return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) .pipe( map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), - catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + catchError(() => observableOf(new RetrieveAuthMethodsErrorAction())), ); - }) + }), )); /** @@ -268,7 +303,7 @@ export class AuthEffects { // in, and start a new timer switchMap(() => // Start a timer outside of Angular's zone - timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)) + timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)), ), // Re-enter the zone to dispatch the action observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 04bbc4acaf..d824df472a 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -1,18 +1,20 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; - import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; -import { AuthInterceptor } from './auth.interceptor'; -import { AuthService } from './auth.service'; -import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; -import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { RestRequestMethod } from '../data/rest-request-method'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { AuthInterceptor } from './auth.interceptor'; +import { AuthService } from './auth.service'; describe(`AuthInterceptor`, () => { let service: DspaceRestService; @@ -20,10 +22,8 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - select: observableOf(true) + select: observableOf(true), }); beforeEach(() => { @@ -46,6 +46,10 @@ describe(`AuthInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => { @@ -95,14 +99,11 @@ describe(`AuthInterceptor`, () => { }); it('should redirect to login', () => { - - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { - expect(response).toBeTruthy(); - }); - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); + // HttpTestingController.expectNone will throw an error when a requests is made + expect().nothing(); }); }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 672879f436..84d4c53210 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,7 +1,3 @@ -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; - -import { catchError, map } from 'rxjs/operators'; -import { Injectable, Injector } from '@angular/core'; import { HttpErrorResponse, HttpEvent, @@ -10,19 +6,36 @@ import { HttpInterceptor, HttpRequest, HttpResponse, - HttpResponseBase + HttpResponseBase, } from '@angular/common/http'; +import { + Injectable, + Injector, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { + Observable, + of as observableOf, + throwError as observableThrowError, +} from 'rxjs'; +import { + catchError, + map, +} from 'rxjs/operators'; import { AppState } from '../../app.reducer'; -import { AuthService } from './auth.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, + isNotNull, +} from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction } from './auth.actions'; -import { Store } from '@ngrx/store'; -import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -152,12 +165,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 +178,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; @@ -207,8 +220,8 @@ export class AuthInterceptor implements HttpInterceptor { message: 'Unknown auth error', status: 500, timestamp: Date.now(), - path: '' - }; + path: '', + }; } } else { authStatus.error = error; @@ -261,7 +274,7 @@ export class AuthInterceptor implements HttpInterceptor { // login successfully const newToken = response.headers.get('authorization'); authRes = response.clone({ - body: this.makeAuthStatusObject(true, newToken) + body: this.makeAuthStatusObject(true, newToken), }); // clean eventually refresh Requests list @@ -269,13 +282,13 @@ export class AuthInterceptor implements HttpInterceptor { } else if (this.isStatusResponse(response)) { authRes = response.clone({ body: Object.assign(response.body, { - authMethods: this.parseAuthMethodsFromHeaders(response.headers) - }) + authMethods: this.parseAuthMethodsFromHeaders(response.headers), + }), }); } else { // logout successfully authRes = response.clone({ - body: this.makeAuthStatusObject(false) + body: this.makeAuthStatusObject(false), }); } return authRes; @@ -283,7 +296,7 @@ export class AuthInterceptor implements HttpInterceptor { return response; } }), - catchError((error, caught) => { + catchError((error: unknown, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { @@ -298,7 +311,7 @@ export class AuthInterceptor implements HttpInterceptor { headers: error.headers, status: error.status, statusText: error.statusText, - url: error.url + url: error.url, }); return observableOf(authResponse); } else if (this.isUnauthorized(error) && isNotNull(token) && authService.isTokenExpired()) { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index c0619adf79..7860744aa5 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -1,4 +1,4 @@ -import { authReducer, AuthState } from './auth.reducer'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; import { AddAuthenticationMessageAction, AuthenticateAction, @@ -8,7 +8,6 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - SetAuthCookieStatus, CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, @@ -24,15 +23,19 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, SetRedirectUrlAction, SetUserAsIdleAction, - UnsetUserAsIdleAction + UnsetUserAsIdleAction, } from './auth.actions'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; -import { AuthStatus } from './models/auth-status.model'; +import { + authReducer, + AuthState, +} from './auth.reducer'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('authReducer', () => { @@ -47,7 +50,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: false, - idle: false + idle: false, }; const action = new AuthenticateAction('user', 'password'); const newState = authReducer(initialState, action); @@ -58,7 +61,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -72,7 +75,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationSuccessAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -88,7 +91,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticationErrorAction(mockError); const newState = authReducer(initialState, action); @@ -100,7 +103,7 @@ describe('authReducer', () => { info: undefined, authToken: undefined, error: 'Test error message', - idle: false + idle: false, }; expect(newState).toEqual(state); @@ -114,7 +117,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -125,7 +128,7 @@ describe('authReducer', () => { error: undefined, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -138,7 +141,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const newState = authReducer(initialState, action); @@ -150,7 +153,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -163,7 +166,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new AuthenticatedErrorAction(mockError); const newState = authReducer(initialState, action); @@ -175,7 +178,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -186,7 +189,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenAction(); const newState = authReducer(initialState, action); @@ -195,7 +198,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -206,7 +209,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); @@ -215,7 +218,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -227,7 +230,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: false, - idle: false + idle: false, }; const action = new SetAuthCookieStatus(true); const newState = authReducer(initialState, action); @@ -237,7 +240,7 @@ describe('authReducer', () => { blocking: false, loading: true, externalAuth: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -252,7 +255,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutAction(); @@ -271,7 +274,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutSuccessAction(); @@ -286,7 +289,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -301,7 +304,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const action = new LogOutErrorAction(mockError); @@ -315,7 +318,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -329,7 +332,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); @@ -342,7 +345,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -355,7 +358,7 @@ describe('authReducer', () => { blocking: true, loading: true, info: undefined, - idle: false + idle: false, }; const action = new RetrieveAuthenticatedEpersonErrorAction(mockError); const newState = authReducer(initialState, action); @@ -367,7 +370,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -382,7 +385,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -397,7 +400,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -413,7 +416,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenSuccessAction(newTokenInfo); @@ -428,7 +431,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: false, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -444,7 +447,7 @@ describe('authReducer', () => { info: undefined, userId: EPersonMock.id, refreshing: true, - idle: false + idle: false, }; const action = new RefreshTokenErrorAction(); const newState = authReducer(initialState, action); @@ -458,7 +461,7 @@ describe('authReducer', () => { info: undefined, refreshing: false, userId: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -473,7 +476,7 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - idle: false + idle: false, }; state = { @@ -485,7 +488,7 @@ describe('authReducer', () => { error: undefined, info: 'Message', userId: undefined, - idle: false + idle: false, }; }); @@ -507,7 +510,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new AddAuthenticationMessageAction('Message'); const newState = authReducer(initialState, action); @@ -517,7 +520,7 @@ describe('authReducer', () => { blocking: false, loading: false, info: 'Message', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -530,7 +533,7 @@ describe('authReducer', () => { loading: false, error: 'Error', info: 'Message', - idle: false + idle: false, }; const action = new ResetAuthenticationMessagesAction(); const newState = authReducer(initialState, action); @@ -541,7 +544,7 @@ describe('authReducer', () => { loading: false, error: undefined, info: undefined, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -552,7 +555,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetRedirectUrlAction('redirect.url'); const newState = authReducer(initialState, action); @@ -562,7 +565,7 @@ describe('authReducer', () => { blocking: false, loading: false, redirectUrl: 'redirect.url', - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -574,7 +577,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsAction(new AuthStatus()); const newState = authReducer(initialState, action); @@ -584,7 +587,7 @@ describe('authReducer', () => { blocking: false, loading: true, authMethods: [], - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -596,11 +599,11 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + 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); @@ -610,7 +613,7 @@ describe('authReducer', () => { blocking: false, loading: false, authMethods: authMethods, - idle: false + idle: false, }; expect(newState).toEqual(state); }); @@ -622,7 +625,7 @@ describe('authReducer', () => { blocking: true, loading: true, authMethods: [], - idle: false + idle: false, }; const action = new RetrieveAuthMethodsErrorAction(); @@ -632,8 +635,8 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - authMethods: [new AuthMethod(AuthMethodType.Password)], - idle: false + authMethods: [new AuthMethod(AuthMethodType.Password, 0)], + idle: false, }; expect(newState).toEqual(state); }); @@ -644,7 +647,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + idle: false, }; const action = new SetUserAsIdleAction(); @@ -654,7 +657,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; expect(newState).toEqual(state); }); @@ -665,7 +668,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: true + idle: true, }; const action = new UnsetUserAsIdleAction(); @@ -675,7 +678,7 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - idle: false + 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..8a399710ea 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -1,4 +1,5 @@ // import actions +import { StoreActionTypes } from '../../store.actions'; import { AddAuthenticationMessageAction, AuthActions, @@ -10,14 +11,14 @@ import { RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, - RetrieveAuthMethodsSuccessAction, SetAuthCookieStatus, - SetRedirectUrlAction + RetrieveAuthMethodsSuccessAction, + SetAuthCookieStatus, + SetRedirectUrlAction, } from './auth.actions'; -// import models -import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; -import { StoreActionTypes } from '../../store.actions'; +// import models +import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -76,7 +77,7 @@ const initialState: AuthState = { loading: false, authMethods: [], externalAuth: false, - idle: false + idle: false, }; /** @@ -92,13 +93,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { error: undefined, loading: true, - info: undefined + info: undefined, }); case AuthActionTypes.AUTHENTICATED: return Object.assign({}, state, { loading: true, - blocking: true + blocking: true, }); case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: @@ -109,7 +110,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.SET_AUTH_COOKIE_STATUS: return Object.assign({}, state, { - externalAuth: (action as SetAuthCookieStatus).payload + externalAuth: (action as SetAuthCookieStatus).payload, }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -120,13 +121,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: (action as AuthenticationErrorAction).payload.message, loaded: true, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { authenticated: true, - authToken: (action as AuthenticatedSuccessAction).payload.authToken + authToken: (action as AuthenticatedSuccessAction).payload.authToken, }); case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: @@ -136,7 +137,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, blocking: false, info: undefined, - userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload + userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload, }); case AuthActionTypes.AUTHENTICATE_ERROR: @@ -145,7 +146,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, blocking: false, - loading: false + loading: false, }); case AuthActionTypes.AUTHENTICATE_SUCCESS: @@ -155,7 +156,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, - error: (action as LogOutErrorAction).payload.message + error: (action as LogOutErrorAction).payload.message, }); case AuthActionTypes.REFRESH_TOKEN_ERROR: @@ -168,7 +169,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.LOG_OUT_SUCCESS: @@ -181,7 +182,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: true, info: undefined, refreshing: false, - userId: undefined + userId: undefined, }); case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: @@ -193,7 +194,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, - userId: undefined + userId: undefined, }); case AuthActionTypes.REFRESH_TOKEN: @@ -205,7 +206,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authToken: (action as RefreshTokenSuccessAction).payload, refreshing: false, - blocking: false + blocking: false, }); case AuthActionTypes.ADD_MESSAGE: @@ -229,14 +230,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { loading: false, blocking: false, - authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload, }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: 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.spec.ts b/src/app/core/auth/auth.service.spec.ts index b38d17aecd..7e2f4f5fe1 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,46 +1,75 @@ -import { inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Store, StoreModule } from '@ngrx/store'; +import { + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + Store, + StoreModule, +} from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; -import { Observable, of as observableOf } from 'rxjs'; -import { authReducer, AuthState } from './auth.reducer'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; -import { RouterStub } from '../../shared/testing/router.stub'; -import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { CookieService } from '../services/cookie.service'; -import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; -import { AuthRequestService } from './auth-request.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPerson } from '../eperson/models/eperson.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { TranslateService } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AppState } from '../../app.reducer'; -import { ClientCookieService } from '../services/client-cookie.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; +import { authMethodsMock } from '../../shared/testing/auth-service.stub'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; -import { RouteService } from '../services/route.service'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { + SpecialGroupDataMock, + SpecialGroupDataMock$, +} from '../../shared/testing/special-group.mock'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { authMethodsMock } from '../../shared/testing/auth-service.stub'; -import { AuthMethod } from './models/auth.method'; +import { EPerson } from '../eperson/models/eperson.model'; +import { ClientCookieService } from '../services/client-cookie.service'; +import { CookieService } from '../services/cookie.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; -import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock'; -import { cold } from 'jasmine-marbles'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + SetUserAsIdleAction, + UnsetUserAsIdleAction, +} from './auth.actions'; +import { + authReducer, + AuthState, +} from './auth.reducer'; +import { + AuthService, + IMPERSONATING_COOKIE, +} from './auth.service'; +import { AuthRequestService } from './auth-request.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; describe('AuthService test', () => { const mockEpersonDataService: any = { findByHref(href: string): Observable> { return createSuccessfulRemoteDataObject$(EPersonMock); - } + }, }; let mockStore: Store; @@ -62,13 +91,13 @@ describe('AuthService test', () => { uuid: 'test', authenticated: true, okay: true, - specialGroups: SpecialGroupDataMock$ + specialGroups: SpecialGroupDataMock$, }); function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, - pipe: observableOf(true) + pipe: observableOf(true), }); window = new NativeWindowRef(); routerStub = new RouterStub(); @@ -80,7 +109,7 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: false + idle: false, }; unAuthenticatedState = { authenticated: false, @@ -88,7 +117,7 @@ describe('AuthService test', () => { loading: false, authToken: undefined, user: undefined, - idle: false + idle: false, }; idleState = { authenticated: true, @@ -96,12 +125,12 @@ describe('AuthService test', () => { loading: false, authToken: token, user: EPersonMock, - idle: true + idle: true, }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { - resolveLinks: {} + resolveLinks: {}, }; hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); @@ -117,8 +146,8 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } + strictActionImmutability: false, + }, }), ], declarations: [], @@ -135,7 +164,7 @@ describe('AuthService test', () => { { provide: NotificationsService, useValue: NotificationsServiceStub }, { provide: TranslateService, useValue: getMockTranslateService() }, CookieService, - AuthService + AuthService, ], }); authService = TestBed.inject(AuthService); @@ -238,9 +267,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -249,8 +278,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -313,9 +342,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -325,8 +354,8 @@ describe('AuthService test', () => { { provide: RemoteDataBuildService, useValue: linkService }, ClientCookieService, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -338,7 +367,7 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: expiredToken, - user: EPersonMock + user: EPersonMock, }; store .subscribe((state) => { @@ -528,7 +557,7 @@ describe('AuthService test', () => { it('should call navigateToRedirectUrl with no url', () => { const expectRes = cold('(a|)', { - a: SpecialGroupDataMock + a: SpecialGroupDataMock, }); expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); }); @@ -543,9 +572,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -554,8 +583,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); @@ -583,9 +612,9 @@ describe('AuthService test', () => { StoreModule.forRoot({ authReducer }, { runtimeChecks: { strictStateImmutability: false, - strictActionImmutability: false - } - }) + strictActionImmutability: false, + }, + }), ], providers: [ { provide: AuthRequestService, useValue: authRequest }, @@ -594,8 +623,8 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: linkService }, CookieService, - AuthService - ] + AuthService, + ], }).compileComponents(); })); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 6604936cde..7da72b25b6 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,18 +1,34 @@ -import { Inject, Injectable, Optional } from '@angular/core'; -import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; - -import { Observable, of as observableOf } from 'rxjs'; -import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + Inject, + Injectable, + Optional, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; +import { + REQUEST, + RESPONSE, +} from '@nguniversal/express-engine/tokens'; +import { TranslateService } from '@ngx-translate/core'; import { CookieAttributes } from 'js-cookie'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + startWith, + switchMap, + take, +} from 'rxjs/operators'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthRequestService } from './auth-request.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; +import { environment } from '../../../environments/environment'; +import { AppState } from '../../app.reducer'; import { hasNoValue, hasValue, @@ -20,42 +36,58 @@ import { isEmpty, isNotEmpty, isNotNull, - isNotUndefined + isNotUndefined, } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { EPerson } from '../eperson/models/eperson.model'; +import { Group } from '../eperson/models/group.model'; import { CookieService } from '../services/cookie.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { RouteService } from '../services/route.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../services/window.service'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, +} from '../shared/operators'; +import { PageInfo } from '../shared/page-info.model'; +import { + CheckAuthenticationTokenAction, + RefreshTokenAction, + ResetAuthenticationMessagesAction, + SetAuthCookieStatus, + SetRedirectUrlAction, + SetUserAsIdleAction, + UnsetUserAsIdleAction, +} from './auth.actions'; +import { AuthRequestService } from './auth-request.service'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; +import { + AuthTokenInfo, + TOKENITEM, +} from './models/auth-token-info.model'; import { getAuthenticatedUserId, - getAuthenticationToken, getExternalAuthCookieStatus, + getAuthenticationToken, + getExternalAuthCookieStatus, getRedirectUrl, isAuthenticated, isAuthenticatedLoaded, isIdle, - isTokenRefreshing + isTokenRefreshing, } from './selectors'; -import { AppState } from '../../app.reducer'; -import { - CheckAuthenticationTokenAction, - RefreshTokenAction, - ResetAuthenticationMessagesAction, SetAuthCookieStatus, - SetRedirectUrlAction, - SetUserAsIdleAction, - UnsetUserAsIdleAction -} from './auth.actions'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { RouteService } from '../services/route.service'; -import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; -import { AuthMethod } from './models/auth.method'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { RemoteData } from '../data/remote-data'; -import { environment } from '../../../environments/environment'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model'; -import { Group } from '../eperson/models/group.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PageInfo } from '../shared/page-info.model'; -import { followLink } from '../../shared/utils/follow-link-config.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -90,13 +122,13 @@ export class AuthService { protected store: Store, protected hardRedirectService: HardRedirectService, private notificationService: NotificationsService, - private translateService: TranslateService + private translateService: TranslateService, ) { this.store.pipe( // when this service is constructed the store is not fully initialized yet filter((state: any) => state?.core?.auth !== undefined), select(isAuthenticated), - startWith(false) + startWith(false), ).subscribe((authenticated: boolean) => this._authenticated = authenticated); } @@ -119,7 +151,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')); } })); @@ -136,7 +168,7 @@ export class AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } @@ -202,7 +234,7 @@ export class AuthService { */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { return this.epersonService.findByHref(userHref).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -212,7 +244,7 @@ export class AuthService { */ public retrieveAuthenticatedUserById(userId: string): Observable { return this.epersonService.findById(userId).pipe( - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -225,7 +257,7 @@ export class AuthService { select(getAuthenticatedUserId), hasValueOperator(), switchMap((id: string) => this.epersonService.findById(id)), - getAllSucceededRemoteDataPayload() + getAllSucceededRemoteDataPayload(), ); } @@ -248,7 +280,7 @@ export class AuthService { } else { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); } - }) + }), ); } @@ -260,15 +292,14 @@ export class AuthService { select(getAuthenticationToken), take(1), map((authTokenInfo: AuthTokenInfo) => { - let token: AuthTokenInfo; // Retrieve authentication token info and check if is valid - token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); + const token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken) && !this.isTokenExpired(token)) { return token; } else { throw false; } - }) + }), ); } @@ -412,7 +443,7 @@ export class AuthService { const token = this.getToken(); return token.expires - (60 * 5 * 1000) < Date.now(); } - }) + }), ); } @@ -437,7 +468,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; // Save cookie with the token return this.storage.set(TOKENITEM, token, options); @@ -516,7 +547,7 @@ export class AuthService { } else { return this.storage.get(REDIRECT_COOKIE); } - }) + }), ); } @@ -529,7 +560,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; this.storage.set(REDIRECT_COOKIE, url, options); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } @@ -609,7 +640,7 @@ export class AuthService { */ getShortlivedToken(): Observable { return this.isAuthenticated().pipe( - switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)), ); } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 1ab1d2e0a5..4a989e0979 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -4,16 +4,28 @@ import { CanActivate, Router, RouterStateSnapshot, - UrlTree + UrlTree, } from '@angular/router'; - +import { + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { map, find, switchMap } from 'rxjs/operators'; -import { select, Store } from '@ngrx/store'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; -import { AuthService, LOGIN_ROUTE } from './auth.service'; import { CoreState } from '../core-state.model'; +import { + AuthService, + LOGIN_ROUTE, +} from './auth.service'; +import { + isAuthenticated, + isAuthenticationLoading, +} from './selectors'; /** * Prevent unauthorized activating and loading of routes @@ -59,7 +71,7 @@ export class AuthenticatedGuard implements CanActivate { this.authService.removeToken(); return this.router.createUrlTree([LOGIN_ROUTE]); } - }) + }), ); } } diff --git a/src/app/core/auth/browser-auth-request.service.spec.ts b/src/app/core/auth/browser-auth-request.service.spec.ts index b41d981bcf..9649255b23 100644 --- a/src/app/core/auth/browser-auth-request.service.spec.ts +++ b/src/app/core/auth/browser-auth-request.service.spec.ts @@ -1,8 +1,9 @@ -import { AuthRequestService } from './auth-request.service'; -import { RequestService } from '../data/request.service'; -import { BrowserAuthRequestService } from './browser-auth-request.service'; import { Observable } from 'rxjs'; + import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { AuthRequestService } from './auth-request.service'; +import { BrowserAuthRequestService } from './browser-auth-request.service'; describe(`BrowserAuthRequestService`, () => { let href: string; @@ -12,7 +13,7 @@ describe(`BrowserAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); service = new BrowserAuthRequestService(null, requestService, null); }); diff --git a/src/app/core/auth/browser-auth-request.service.ts b/src/app/core/auth/browser-auth-request.service.ts index 485e2ef9c4..d708cd8982 100644 --- a/src/app/core/auth/browser-auth-request.service.ts +++ b/src/app/core/auth/browser-auth-request.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; -import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { AuthRequestService } from './auth-request.service'; /** * Client side version of the service to send authentication requests @@ -15,7 +19,7 @@ export class BrowserAuthRequestService extends AuthRequestService { constructor( halService: HALEndpointService, requestService: RequestService, - rdbService: RemoteDataBuildService + rdbService: RemoteDataBuildService, ) { super(halService, requestService, rdbService); } diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index d18b1ccf9a..1a6938887d 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,7 +1,17 @@ -import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { + autoserialize, + deserialize, + deserializeAs, +} from 'cerialize'; import { Observable } from 'rxjs'; -import { link, typedObject } from '../../cache/builders/build-decorators'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; @@ -10,12 +20,10 @@ import { GROUP } from '../../eperson/models/group.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { AuthMethod } from './auth.method'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; -import { AuthMethod } from './auth.method'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * Object that represents the authenticated status of a user @@ -28,14 +36,14 @@ export class AuthStatus implements CacheableObject { * The unique identifier of this auth status */ @autoserialize - id: string; + id: string; /** * The type for this AuthStatus */ @excludeFromEquals @autoserialize - type: ResourceType; + type: ResourceType; /** * The UUID of this auth status @@ -43,25 +51,25 @@ export class AuthStatus implements CacheableObject { * It is based on the ID, so it will be the same for each refresh. */ @deserializeAs(new IDToUUIDSerializer('auth-status'), 'id') - uuid: string; + uuid: string; /** * True if REST API is up and running, should never return false */ @autoserialize - okay: boolean; + okay: boolean; /** * If the auth status represents an authenticated state */ @autoserialize - authenticated: boolean; + authenticated: boolean; /** * The {@link HALLink}s for this AuthStatus */ @deserialize - _links: { + _links: { self: HALLink; eperson: HALLink; specialGroups: HALLink; @@ -72,32 +80,32 @@ export class AuthStatus implements CacheableObject { * Will be undefined unless the eperson {@link HALLink} has been resolved. */ @link(EPERSON) - eperson?: Observable>; + eperson?: Observable>; /** * The SpecialGroup of this auth status * Will be undefined unless the SpecialGroup {@link HALLink} has been resolved. */ @link(GROUP, true) - specialGroups?: Observable>>; + specialGroups?: Observable>>; /** * True if the token is valid, false if there was no token or the token wasn't valid */ @autoserialize - token?: AuthTokenInfo; + token?: AuthTokenInfo; /** * Authentication error if there was one for this status */ // TODO should be refactored to use the RemoteData error @autoserialize - error?: AuthError; + error?: AuthError; /** * All authentication methods enabled at the backend */ @autoserialize - authMethods: AuthMethod[]; + authMethods: AuthMethod[]; } 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/auth/models/short-lived-token.model.ts b/src/app/core/auth/models/short-lived-token.model.ts index 3786bd8e6a..d91a26e990 100644 --- a/src/app/core/auth/models/short-lived-token.model.ts +++ b/src/app/core/auth/models/short-lived-token.model.ts @@ -1,10 +1,15 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; -import { ResourceType } from '../../shared/resource-type'; -import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; -import { HALLink } from '../../shared/hal-link.model'; import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; /** * A short-lived token that can be used to authenticate a rest request @@ -17,19 +22,19 @@ export class ShortLivedToken implements CacheableObject { */ @excludeFromEquals @autoserialize - type: ResourceType; + type: ResourceType; /** * The value for this ShortLivedToken */ @autoserializeAs('token') - value: string; + value: string; /** * The {@link HALLink}s for this ShortLivedToken */ @deserialize - _links: { + _links: { self: HALLink; }; } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index aba739edf6..6360377626 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -1,5 +1,7 @@ import { createSelector } from '@ngrx/store'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; /** * Every reducer module's default export is the reducer function itself. In * addition, each module should export a type or interface that describes @@ -7,8 +9,6 @@ import { createSelector } from '@ngrx/store'; * notation packages up all of the exports into a single object. */ import { AuthState } from './auth.reducer'; -import { CoreState } from '../core-state.model'; -import { coreSelector } from '../core.selectors'; /** * Returns the user state. diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index 5b0221e5df..b119f6b3b7 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -1,14 +1,22 @@ -import { AuthRequestService } from './auth-request.service'; -import { RequestService } from '../data/request.service'; -import { ServerAuthRequestService } from './server-auth-request.service'; -import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; -import { Observable, of as observableOf } from 'rxjs'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + HttpClient, + HttpHeaders, + HttpResponse, +} from '@angular/common/http'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { XSRF_REQUEST_HEADER, - XSRF_RESPONSE_HEADER + XSRF_RESPONSE_HEADER, } from '../xsrf/xsrf.constants'; +import { AuthRequestService } from './auth-request.service'; +import { ServerAuthRequestService } from './server-auth-request.service'; describe(`ServerAuthRequestService`, () => { let href: string; @@ -22,20 +30,20 @@ describe(`ServerAuthRequestService`, () => { beforeEach(() => { href = 'https://rest.api/auth/shortlivedtokens'; requestService = jasmine.createSpyObj('requestService', { - 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' + 'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2', }); let headers = new HttpHeaders(); headers = headers.set(XSRF_RESPONSE_HEADER, mockToken); httpResponse = { body: { bar: false }, headers: headers, - statusText: '200' + statusText: '200', } as HttpResponse; httpClient = jasmine.createSpyObj('httpClient', { get: observableOf(httpResponse), }); halService = jasmine.createSpyObj('halService', { - 'getRootHref': '/api' + 'getRootHref': '/api', }); service = new ServerAuthRequestService(halService, requestService, null, httpClient); }); diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index 058322acce..5f1828c71c 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -1,21 +1,22 @@ -import { Injectable } from '@angular/core'; -import { AuthRequestService } from './auth-request.service'; -import { PostRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { - HttpHeaders, HttpClient, - HttpResponse + HttpHeaders, + HttpResponse, } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { + DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, - DSPACE_XSRF_COOKIE } from '../xsrf/xsrf.constants'; -import { map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { AuthRequestService } from './auth-request.service'; /** * Server side version of the service to send authentication requests @@ -45,11 +46,11 @@ export class ServerAuthRequestService extends AuthRequestService { map((response: HttpResponse) => response.headers.get(XSRF_RESPONSE_HEADER)), // Use that token to create an HttpHeaders object map((xsrfToken: string) => new HttpHeaders() - .set('Content-Type', 'application/json; charset=utf-8') - // set the token as the XSRF header - .set(XSRF_REQUEST_HEADER, xsrfToken) - // and as the DSPACE-XSRF-COOKIE - .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), + .set('Content-Type', 'application/json; charset=utf-8') + // set the token as the XSRF header + .set(XSRF_REQUEST_HEADER, xsrfToken) + // and as the DSPACE-XSRF-COOKIE + .set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)), map((headers: HttpHeaders) => // Create a new PostRequest using those headers and the given href new PostRequest( @@ -59,8 +60,8 @@ export class ServerAuthRequestService extends AuthRequestService { { headers: headers, }, - ) - ) + ), + ), ); } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index fc8ab18bfb..f51215abad 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,15 +1,17 @@ -import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; - +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { RemoteData } from '../data/remote-data'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { RemoteData } from '../data/remote-data'; /** * The auth service. @@ -57,7 +59,7 @@ export class ServerAuthService extends AuthService { options.headers = headers; options.withCredentials = true; return this.authRequestService.getRequest('status', options).pipe( - map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)) + map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload)), ); } } diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts index a440325560..f4244e98b2 100644 --- a/src/app/core/auth/token-response-parsing.service.spec.ts +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ -import { TokenResponseParsingService } from './token-response-parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { TokenResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { TokenResponseParsingService } from './token-response-parsing.service'; describe('TokenResponseParsingService', () => { let service: TokenResponseParsingService; @@ -13,10 +13,10 @@ describe('TokenResponseParsingService', () => { it('should return a TokenResponse containing the token', () => { const data = { payload: { - token: 'valid-token' + token: 'valid-token', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -26,7 +26,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; const expected = new TokenResponse(null, false, 200, 'OK'); expect(service.parse(undefined, data)).toEqual(expected); @@ -36,7 +36,7 @@ describe('TokenResponseParsingService', () => { const data = { payload: {}, statusCode: 400, - statusText: 'BAD REQUEST' + statusText: 'BAD REQUEST', } as RawRestResponse; const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); expect(service.parse(undefined, data)).toEqual(expected); diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts index 1ba7a16b14..3990d0f44e 100644 --- a/src/app/core/auth/token-response-parsing.service.ts +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -1,9 +1,13 @@ -import { ResponseParsingService } from '../data/parsing.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, TokenResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; import { Injectable } from '@angular/core'; + +import { isNotEmpty } from '../../shared/empty.util'; +import { + RestResponse, + TokenResponse, +} from '../cache/response.models'; +import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/rest-request.model'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; @Injectable() /** diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts index b2ddade682..f003bd0f85 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Bitstream } from '../shared/bitstream.model'; -import { BitstreamDataService } from '../data/bitstream-data.service'; import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { Bitstream } from '../shared/bitstream.model'; import { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; /** * The class that resolves the BreadcrumbConfig object for an Item */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class BitstreamBreadcrumbResolver extends DSOBreadcrumbResolver { constructor( diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts index 333886ed3d..11a3a74350 100644 --- a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -1,35 +1,46 @@ import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; - -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; -import { DSONameService } from './dso-name.service'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { LinkService } from '../cache/builders/link.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { RemoteData } from '../data/remote-data'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { getDSORoute } from '../../app-routing-paths'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { LinkService } from '../cache/builders/link.service'; import { BitstreamDataService } from '../data/bitstream-data.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { DSONameService } from './dso-name.service'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { constructor( protected bitstreamService: BitstreamDataService, protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { super(linkService, dsoNameService); } @@ -53,7 +64,7 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { return observableOf([]); }), - map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } @@ -74,12 +85,12 @@ export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { } else { return observableOf(undefined); } - }) + }), ); } else { return observableOf(undefined); } - }) + }), ); } } diff --git a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts index 4f5dd0a583..83d9a2caaa 100644 --- a/src/app/core/breadcrumbs/breadcrumbsProviderService.ts +++ b/src/app/core/breadcrumbs/breadcrumbsProviderService.ts @@ -1,6 +1,7 @@ -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { Observable } from 'rxjs'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; + /** * Service to calculate breadcrumbs for a single part of the route */ diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts index 46c49add06..d337da22c7 100644 --- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { CollectionDataService } from '../data/collection-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + import { COLLECTION_PAGE_LINKS_TO_FOLLOW } from '../../collection-page/collection-page.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CollectionDataService } from '../data/collection-data.service'; +import { Collection } from '../shared/collection.model'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** * The class that resolves the BreadcrumbConfig object for a Collection */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) { diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts index 309927771d..4cbffe9a6a 100644 --- a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; + +import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { CommunityDataService } from '../data/community-data.service'; import { Community } from '../shared/community.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../community-page/community-page.resolver'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** * The class that resolves the BreadcrumbConfig object for a Community */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) { diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts index e35e26e46f..59fda031b2 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts @@ -1,8 +1,9 @@ -import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { Collection } from '../shared/collection.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getTestScheduler } from 'jasmine-marbles'; + +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Collection } from '../shared/collection.model'; import { CollectionBreadcrumbResolver } from './collection-breadcrumb.resolver'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; describe('DSOBreadcrumbResolver', () => { describe('resolve', () => { @@ -21,7 +22,7 @@ describe('DSOBreadcrumbResolver', () => { testCollection = Object.assign(new Collection(), { uuid }); dsoBreadcrumbService = {}; collectionService = { - findById: (id: string) => createSuccessfulRemoteDataObject$(testCollection) + findById: (id: string) => createSuccessfulRemoteDataObject$(testCollection), }; resolver = new CollectionBreadcrumbResolver(dsoBreadcrumbService, collectionService); }); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 8be4e5e099..712f40a3c5 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -1,15 +1,23 @@ -import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { map } from 'rxjs/operators'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { map } from 'rxjs/operators'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { hasValue } from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject @@ -43,7 +51,7 @@ export abstract class DSOBreadcrumbResolver { let service: DSOBreadcrumbsService; @@ -43,46 +50,46 @@ describe('DSOBreadcrumbsService', () => { { type: 'community', metadata: { - 'dc.title': [{ value: 'community' }] + 'dc.title': [{ value: 'community' }], }, uuid: communityUUID, parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), _links: { parentCommunity: 'site', - self: communityPath + communityUUID - } - } + self: communityPath + communityUUID, + }, + }, ); testCollection = Object.assign(new Collection(), { type: 'collection', metadata: { - 'dc.title': [{ value: 'collection' }] + 'dc.title': [{ value: 'collection' }], }, uuid: collectionUUID, parentCommunity: createSuccessfulRemoteDataObject$(testCommunity), _links: { parentCommunity: communityPath + communityUUID, - self: communityPath + collectionUUID - } - } + self: communityPath + collectionUUID, + }, + }, ); testItem = Object.assign(new Item(), { type: 'item', metadata: { - 'dc.title': [{ value: 'item' }] + 'dc.title': [{ value: 'item' }], }, uuid: itemUUID, owningCollection: createSuccessfulRemoteDataObject$(testCollection), _links: { owningCollection: collectionPath + collectionUUID, - self: itemPath + itemUUID - } - } + self: itemPath + itemUUID, + }, + }, ); dsoNameService = { getName: (dso) => getName(dso) }; @@ -93,8 +100,8 @@ describe('DSOBreadcrumbsService', () => { TestBed.configureTestingModule({ providers: [ { provide: LinkService, useValue: getMockLinkService() }, - { provide: DSONameService, useValue: dsoNameService } - ] + { provide: DSONameService, useValue: dsoNameService }, + ], }).compileComponents(); })); diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 9a22cd0e35..7d2e3697e1 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -1,27 +1,35 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + find, + map, + switchMap, +} from 'rxjs/operators'; + +import { getDSORoute } from '../../app-routing-paths'; import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { hasValue } from '../../shared/empty.util'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { LinkService } from '../cache/builders/link.service'; +import { RemoteData } from '../data/remote-data'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; import { DSONameService } from './dso-name.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { ChildHALResource } from '../shared/child-hal-resource.model'; -import { LinkService } from '../cache/builders/link.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import { find, map, switchMap } from 'rxjs/operators'; -import { RemoteData } from '../data/remote-data'; -import { hasValue } from '../../shared/empty.util'; -import { Injectable } from '@angular/core'; -import { getDSORoute } from '../../app-routing-paths'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSOBreadcrumbsService implements BreadcrumbsProviderService { constructor( protected linkService: LinkService, - protected dsoNameService: DSONameService + protected dsoNameService: DSONameService, ) { } @@ -46,7 +54,7 @@ export class DSOBreadcrumbsService implements BreadcrumbsProviderService [...breadcrumbs, crumb]) + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]), ); } } diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 9f2f76599a..90b08dca14 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -22,7 +22,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['Person', Item, DSpaceObject]; - } + }, }); mockOrgUnitName = 'Molecular Spectroscopy'; @@ -32,7 +32,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return ['OrgUnit', Item, DSpaceObject]; - } + }, }); mockDSOName = 'Lorem Ipsum'; @@ -42,7 +42,7 @@ describe(`DSONameService`, () => { }, getRenderTypes(): (string | GenericConstructor)[] { return [DSpaceObject]; - } + }, }); service = new DSONameService({ instant: (a) => a } as any); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index ddd97705b0..988141209f 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@angular/core'; -import { hasValue, isEmpty } from '../../shared/empty.util'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; + +import { + hasValue, + isEmpty, +} from '../../shared/empty.util'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { Metadata } from '../shared/metadata.utils'; /** @@ -9,7 +13,7 @@ import { Metadata } from '../shared/metadata.utils'; * on its render types. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class DSONameService { @@ -50,12 +54,12 @@ 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 return dso.firstMetadataValue('dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); - } + }, }; /** @@ -106,7 +110,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/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts index 0d1870487a..05ef2969f7 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -1,5 +1,5 @@ -import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; describe('I18nBreadcrumbResolver', () => { describe('resolve', () => { @@ -17,13 +17,13 @@ describe('I18nBreadcrumbResolver', () => { route = { data: { breadcrumbKey: i18nKey }, routeConfig: { - path: segment + path: segment, }, parent: { routeConfig: { - path: parentSegment - } - } as any + path: parentSegment, + }, + } as any, }; expectedPath = new URLCombiner(parentSegment, segment).toString(); i18nBreadcrumbService = {}; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts index b3fadbbaa9..afcd461de8 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -1,15 +1,20 @@ -import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; import { hasNoValue } from '../../shared/empty.util'; import { currentPathFromSnapshot } from '../../shared/utils/route.utils'; +import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; /** * The class that resolves a BreadcrumbConfig object with an i18n key string for a route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class I18nBreadcrumbResolver implements Resolve> { constructor(protected breadcrumbService: I18nBreadcrumbsService) { diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts index ac2f244037..3fcd911a46 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts @@ -1,7 +1,14 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { getTestScheduler } from 'jasmine-marbles'; -import { BREADCRUMB_MESSAGE_POSTFIX, I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { + BREADCRUMB_MESSAGE_POSTFIX, + I18nBreadcrumbsService, +} from './i18n-breadcrumbs.service'; describe('I18nBreadcrumbsService', () => { let service: I18nBreadcrumbsService; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts index 15563bdde8..5746f6faf2 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts @@ -1,7 +1,11 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; -import { Observable, of as observableOf } from 'rxjs'; -import { Injectable } from '@angular/core'; /** * The postfix for i18n breadcrumbs @@ -12,7 +16,7 @@ export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; * Service to calculate i18n breadcrumbs for a single part of the route */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class I18nBreadcrumbsService implements BreadcrumbsProviderService { diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index 3005b6f09a..f609cbf3bc 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; + +import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { ItemDataService } from '../data/item-data.service'; import { Item } from '../shared/item.model'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; /** * The class that resolves the BreadcrumbConfig object for an Item */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) { diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..db89d02f75 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts @@ -0,0 +1,52 @@ +import { NavigationBreadcrumbResolver } from './navigation-breadcrumb.resolver'; + +describe('NavigationBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: NavigationBreadcrumbResolver; + let NavigationBreadcrumbService: any; + let i18nKey: string; + let relatedI18nKey: string; + let route: any; + let expectedPath; + let state; + beforeEach(() => { + i18nKey = 'example.key'; + relatedI18nKey = 'related.key'; + route = { + data: { + breadcrumbKey: i18nKey, + relatedRoutes: [ + { + path: '', + data: { breadcrumbKey: relatedI18nKey }, + }, + ], + }, + routeConfig: { + path: 'example', + }, + parent: { + routeConfig: { + path: '', + }, + url: [{ + path: 'base', + }], + } as any, + }; + + state = { + url: '/base/example', + }; + expectedPath = '/base/example:/base'; + NavigationBreadcrumbService = {}; + resolver = new NavigationBreadcrumbResolver(NavigationBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route, state); + const expectedConfig = { provider: NavigationBreadcrumbService, key: `${i18nKey}:${relatedI18nKey}`, url: expectedPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts new file mode 100644 index 0000000000..594c1a694f --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +/** + * The class that resolves a BreadcrumbConfig object with an i18n key string for a route and related parents + */ +@Injectable({ + providedIn: 'root', +}) +export class NavigationBreadcrumbResolver implements Resolve> { + + private parentRoutes: ActivatedRouteSnapshot[] = []; + constructor(protected breadcrumbService: NavigationBreadcrumbsService) { + } + + /** + * Method to collect all parent routes snapshot from current route snapshot + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + */ + private getParentRoutes(route: ActivatedRouteSnapshot): void { + if (route.parent) { + this.parentRoutes.push(route.parent); + this.getParentRoutes(route.parent); + } + } + /** + * Method for resolving an I18n breadcrumb configuration object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + this.getParentRoutes(route); + const relatedRoutes = route.data.relatedRoutes; + const parentPaths = this.parentRoutes.map(parent => parent.routeConfig?.path); + const relatedParentRoutes = relatedRoutes.filter(relatedRoute => parentPaths.includes(relatedRoute.path)); + const baseUrlSegmentPath = route.parent.url[route.parent.url.length - 1].path; + const baseUrl = state.url.substring(0, state.url.lastIndexOf(baseUrlSegmentPath) + baseUrlSegmentPath.length); + + + const combinedParentBreadcrumbKeys = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${current.data.breadcrumbKey}`; + }, route.data.breadcrumbKey); + const combinedUrls = relatedParentRoutes.reduce((previous, current) => { + return `${previous}:${baseUrl}${current.path}`; + }, state.url); + + return { provider: this.breadcrumbService, key: combinedParentBreadcrumbKeys, url: combinedUrls }; + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts new file mode 100644 index 0000000000..2da8b06eab --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; + +/** + * The postfix for i18n breadcrumbs + */ +export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; + +/** + * Service to calculate i18n breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root', +}) +export class NavigationBreadcrumbsService implements BreadcrumbsProviderService { + + /** + * 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 keys = key.split(':'); + const urls = url.split(':'); + const breadcrumbs = keys.map((currentKey, index) => new Breadcrumb(currentKey + BREADCRUMB_MESSAGE_POSTFIX, urls[index] )); + return observableOf(breadcrumbs.reverse()); + } +} diff --git a/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts new file mode 100644 index 0000000000..646b967fe5 --- /dev/null +++ b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts @@ -0,0 +1,47 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BREADCRUMB_MESSAGE_POSTFIX } from './i18n-breadcrumbs.service'; +import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service'; + +describe('NavigationBreadcrumbsService', () => { + let service: NavigationBreadcrumbsService; + let exampleString; + let exampleURL; + let childrenString; + let childrenUrl; + let parentString; + let parentUrl; + + function init() { + exampleString = 'example.string:parent.string'; + exampleURL = 'example.com:parent.com'; + childrenString = 'example.string'; + childrenUrl = 'example.com'; + parentString = 'parent.string'; + parentUrl = 'parent.com'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new NavigationBreadcrumbsService(); + }); + + describe('getBreadcrumbs', () => { + it('should return an array of breadcrumbs based on strings by adding the postfix', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [ + new Breadcrumb(childrenString + BREADCRUMB_MESSAGE_POSTFIX, childrenUrl), + new Breadcrumb(parentString + BREADCRUMB_MESSAGE_POSTFIX, parentUrl), + ].reverse() }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..7a0e9d43ed --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; + +describe('PublicationClaimBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: PublicationClaimBreadcrumbResolver; + let publicationClaimBreadcrumbService: any; + const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + let route; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + targetId: expectedId, + }, + }; + publicationClaimBreadcrumbService = {}; + resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, { url: fullPath } as any); + const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts new file mode 100644 index 0000000000..7bcff921e1 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +@Injectable({ + providedIn: 'root', +}) +export class PublicationClaimBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: PublicationClaimBreadcrumbService) { + } + + /** + * Method that resolve Publication Claim item into a breadcrumb + * The parameter are retrieved by the url since part of the Publication Claim route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: this.breadcrumbService, key: targetId }; + } +} diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..8424b5edda --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -0,0 +1,55 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of } from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +describe('PublicationClaimBreadcrumbService', () => { + let service: PublicationClaimBreadcrumbService; + let dsoNameService: any = { + getName: (str) => str, + }; + let translateService: any = { + instant: (str) => str, + }; + + let dataService: any = { + findById: (str) => createSuccessfulRemoteDataObject$(str), + }; + + let authorizationService: any = { + isAuthorized: (str) => of(true), + }; + + let exampleKey; + + const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + function init() { + exampleKey = 'suggestion.suggestionFor.breadcrumb'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleKey); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(exampleKey, undefined)], + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts new file mode 100644 index 0000000000..43b7ed5761 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + combineLatest, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { DSONameService } from './dso-name.service'; + +/** + * Service to calculate Publication claims breadcrumbs + */ +@Injectable({ + providedIn: 'root', +}) +export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { + private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + constructor(private dataService: ItemDataService, + private dsoNameService: DSONameService, + private tranlsateService: TranslateService, + protected authorizationService: AuthorizationDataService) { + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + */ + getBreadcrumbs(key: string): Observable { + return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe( + map(([item, isAdmin]) => { + const itemName = this.dsoNameService.getName(item.payload); + return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', { name: itemName }), undefined)] : + [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', { name: itemName }), undefined)]; + }), + ); + } +} 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..ea6045c85e --- /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..832cd5d08c --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; + +import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; + +@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..f8d30754ca --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts @@ -0,0 +1,43 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service'; + +describe('QualityAssuranceBreadcrumbService', () => { + let service: QualityAssuranceBreadcrumbService; + 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(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..580a5e5f8e --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; + +/** + * 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( + 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 args = key.split(':'); + const sourceId = args[0]; + const topicId = args.length > 2 ? args[args.length - 1] : args[1]; + + if (topicId) { + return observableOf( [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/browse/browse-definition-data.service.spec.ts b/src/app/core/browse/browse-definition-data.service.spec.ts index f321c2551c..affa63a548 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -1,11 +1,12 @@ -import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; -import { FindListOptions } from '../data/find-list-options.model'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { RequestService } from '../data/request.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; + import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestService } from '../data/request.service'; +import { BrowseDefinitionDataService } from './browse-definition-data.service'; describe(`BrowseDefinitionDataService`, () => { let requestService: RequestService; @@ -18,7 +19,7 @@ describe(`BrowseDefinitionDataService`, () => { const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), - followLink('items') + followLink('items'), ]; function initTestService() { diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index bc495a51f4..b823f42076 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,33 +1,47 @@ // eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; -import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteData } from '../data/remote-data'; -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'; -import { dataService } from '../data/base/data-service.decorator'; -import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { take } from 'rxjs/operators'; -import { BrowseDefinitionRestRequest } from '../data/request.models'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; -import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { dataService } from '../data/base/data-service.decorator'; +import { + FindAllData, + FindAllDataImpl, +} from '../data/base/find-all-data'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../data/base/search-data'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RemoteData } from '../data/remote-data'; +import { BrowseDefinitionRestRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; /** * Create a GET request for the given href, and send it. * Use a GET request specific for BrowseDefinitions. */ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, - responseMsToLive: number, - href$: string | Observable, - useCachedVersionIfAvailable: boolean = true): void => { + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { if (isNotEmpty(href$)) { if (typeof href$ === 'string') { href$ = observableOf(href$); @@ -35,7 +49,7 @@ export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestS href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = requestService.generateRequestId(); const request = new BrowseDefinitionRestRequest(requestId, href); @@ -150,7 +164,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService { let scheduler: TestScheduler; @@ -31,26 +42,26 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ - 'dc.date.issued' + 'dc.date.issued', ], _links: { self: { href: 'https://rest.api/discover/browses/dateissued' }, - items: { href: 'https://rest.api/discover/browses/dateissued/items' } - } + items: { href: 'https://rest.api/discover/browses/dateissued/items' }, + }, }), Object.assign(new ValueListBrowseDefinition(), { id: 'author', @@ -58,28 +69,28 @@ describe('BrowseService', () => { sortOptions: [ { name: 'title', - metadata: 'dc.title' + metadata: 'dc.title', }, { name: 'dateissued', - metadata: 'dc.date.issued' + metadata: 'dc.date.issued', }, { name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } + metadata: 'dc.date.accessioned', + }, ], defaultSortOrder: 'ASC', type: 'browse', metadataKeys: [ 'dc.contributor.*', - 'dc.creator' + 'dc.creator', ], _links: { self: { href: 'https://rest.api/discover/browses/author' }, entries: { href: 'https://rest.api/discover/browses/author/entries' }, - items: { href: 'https://rest.api/discover/browses/author/items' } - } + items: { href: 'https://rest.api/discover/browses/author/items' }, + }, }), Object.assign(new HierarchicalBrowseDefinition(), { id: 'srsc', @@ -88,14 +99,14 @@ describe('BrowseService', () => { vocabulary: 'srsc', type: 'browse', metadata: [ - 'dc.subject' + 'dc.subject', ], _links: { vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, - self: { 'href': 'https://rest.api/discover/browses/srsc' } - } + self: { 'href': 'https://rest.api/discover/browses/srsc' }, + }, }), ]; @@ -104,13 +115,13 @@ describe('BrowseService', () => { const getRequestEntry$ = (successful: boolean) => { return observableOf({ - response: { isSuccessful: successful, payload: browseDefinitions } as any + response: { isSuccessful: successful, payload: browseDefinitions } as any, } as RequestEntry); }; function initTestService() { browseDefinitionDataService = jasmine.createSpyObj('browseDefinitionDataService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)) + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(browseDefinitions)), }); hrefOnlyDataService = getMockHrefOnlyDataService(); return new BrowseService( @@ -118,7 +129,7 @@ describe('BrowseService', () => { halService, browseDefinitionDataService, hrefOnlyDataService, - rdbService + rdbService, ); } @@ -164,7 +175,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -178,7 +189,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); @@ -193,7 +204,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expected + a: expected, })); }); }); @@ -208,7 +219,7 @@ describe('BrowseService', () => { service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('--a-', { - a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)) + a: createSuccessfulRemoteDataObject(createPaginatedList(browseDefinitions)), })); }); @@ -290,7 +301,7 @@ describe('BrowseService', () => { scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { - a: expectedURL + a: expectedURL, })); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index b210b34949..a724673d32 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,33 +1,45 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { + distinctUntilChanged, + map, + startWith, +} from 'rxjs/operators'; + +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SortDirection } from '../cache/models/sort-options.model'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { getBrowseDefinitionLinks, getFirstOccurrence, - getRemoteDataPayload, getFirstSucceededRemoteData, - getPaginatedListPayload + getPaginatedListPayload, + getRemoteDataPayload, } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { HrefOnlyDataService } from '../data/href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { SortDirection } from '../cache/models/sort-options.model'; - +import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('thumbnail') + followLink('thumbnail'), ]; /** @@ -102,10 +114,10 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); if (options.fetchThumbnail ) { - return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...BROWSE_LINKS_TO_FOLLOW); } return this.hrefOnlyDataService.findListByHref(href$); } @@ -153,7 +165,7 @@ export class BrowseService { }), ); if (options.fetchThumbnail) { - return this.hrefOnlyDataService.findListByHref(href$, {}, null, null, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...BROWSE_LINKS_TO_FOLLOW); } return this.hrefOnlyDataService.findListByHref(href$); } @@ -187,12 +199,12 @@ export class BrowseService { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } return href; - }) + }), ); return this.hrefOnlyDataService.findListByHref(href$).pipe( getFirstSucceededRemoteData(), - getFirstOccurrence() + getFirstOccurrence(), ); } @@ -248,7 +260,7 @@ export class BrowseService { } return isNotEmpty(matchingKeys); - }) + }), ), map((def: BrowseDefinition) => { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { @@ -258,7 +270,7 @@ export class BrowseService { } }), startWith(undefined), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 150a07f006..53f4cb2f7f 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,7 +1,12 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { getLinkDefinition, link } from './build-decorators'; +import { + dataService, + getDataServiceFor, + getLinkDefinition, + link, +} from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -46,5 +51,17 @@ describe('build decorators', () => { expect(result).toBeUndefined(); }); }); + + describe(`set data service`, () => { + it(`should throw error`, () => { + expect(dataService(null)).toThrow(); + }); + + it(`should set properly data service for type`, () => { + const target = new TestHALResource(); + dataService(testType)(target); + expect(getDataServiceFor(testType)).toEqual(target); + }); + }); }); }); diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 9e5ebaed85..be3ffc0f4d 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -1,25 +1,34 @@ -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { InjectionToken } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { CacheableObject } from '../cacheable-object.model'; import { getResourceTypeValueFor } from '../object-cache.reducer'; -import { InjectionToken } from '@angular/core'; import { TypedObject } from '../typed-object.model'; +export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { + providedIn: 'root', + factory: () => getDataServiceFor, +}); export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', { providedIn: 'root', factory: () => getLinkDefinition, }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', - factory: () => getLinkDefinitions + factory: () => getLinkDefinitions, }); const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); +const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -38,6 +47,39 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType): any { + return (target: any) => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); + } + + dataServiceMap.set(resourceType.value, target); + }; +} + +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType) { + return dataServiceMap.get(resourceType.value); +} + /** * A class to represent the data that can be set by the @link decorator */ @@ -65,7 +107,7 @@ export const link = ( resourceType: ResourceType, isList = false, linkName?: keyof T['_links'], - ) => { +) => { return (target: T, propertyName: string) => { let targetMap = linkMap.get(target.constructor); @@ -81,7 +123,7 @@ export const link = ( resourceType, isList, linkName, - propertyName + propertyName, }); linkMap.set(target.constructor, targetMap); diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index 0ddfe05870..0bb5e4f238 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -1,15 +1,22 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { isEmpty } from 'rxjs/operators'; + +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; +import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; +import { FindListOptions } from '../../data/find-list-options.model'; import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { + LINK_DEFINITION_FACTORY, + LINK_DEFINITION_MAP_FACTORY, +} from './build-decorators'; import { LinkService } from './link.service'; -import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; -import { isEmpty } from 'rxjs/operators'; -import { FindListOptions } from '../../data/find-list-options.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; const TEST_MODEL = new ResourceType('testmodel'); let result: any; @@ -54,15 +61,15 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' + href: 'http://predecessor.link', }, successor: { - href: 'http://successor.link' + href: 'http://successor.link', }, - } + }, }); testDataService = new TestDataService(); spyOn(testDataService, 'findListByHref').and.callThrough(); @@ -70,7 +77,7 @@ describe('LinkService', () => { TestBed.configureTestingModule({ providers: [LinkService, { provide: TestDataService, - useValue: testDataService + useValue: testDataService, }, { provide: DATA_SERVICE_FACTORY, useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService), @@ -79,7 +86,7 @@ describe('LinkService', () => { useValue: jasmine.createSpy('getLinkDefinition').and.returnValue({ resourceType: TEST_MODEL, linkName: 'predecessor', - propertyName: 'predecessor' + propertyName: 'predecessor', }), }, { provide: LINK_DEFINITION_MAP_FACTORY, @@ -93,9 +100,9 @@ describe('LinkService', () => { resourceType: TEST_MODEL, linkName: 'successor', propertyName: 'successor', - } + }, ]), - }] + }], }); service = TestBed.inject(LinkService); }); @@ -115,7 +122,7 @@ describe('LinkService', () => { resourceType: TEST_MODEL, linkName: 'predecessor', propertyName: 'predecessor', - isList: true + isList: true, }); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); @@ -213,12 +220,12 @@ describe('LinkService', () => { value: 'a test value', _links: { self: { - href: 'http://self.link' + href: 'http://self.link', }, predecessor: { - href: 'http://predecessor.link' - } - } + href: 'http://predecessor.link', + }, + }, }); }); @@ -237,7 +244,7 @@ describe('LinkService', () => { ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({ resourceType: TEST_MODEL, linkName: 'successor', - propertyName: 'successor' + propertyName: 'successor', }); result = service.resolveLinks(testModel, followLink('successor')); }); diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index afc7ab88e4..78cd28085e 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -1,19 +1,31 @@ -import { Inject, Injectable, Injector } from '@angular/core'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + Inject, + Injectable, + Injector, +} from '@angular/core'; +import { + EMPTY, + Observable, +} from 'rxjs'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; +import { HALDataService } from '../../data/base/hal-data-service.interface'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; -import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; +import { ResourceType } from '../../shared/resource-type'; import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY, LinkDefinition, } from './build-decorators'; -import { RemoteData } from '../../data/remote-data'; -import { EMPTY, Observable } from 'rxjs'; -import { ResourceType } from '../../shared/resource-type'; -import { HALDataService } from '../../data/base/hal-data-service.interface'; -import { PaginatedList } from '../../data/paginated-list.model'; /** * A Service to handle the resolving and removing diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index d9b856bb77..ec756ce85e 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -1,26 +1,43 @@ -import { createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { buildPaginatedList, PaginatedList } from '../../data/paginated-list.model'; -import { Item } from '../../shared/item.model'; -import { PageInfo } from '../../shared/page-info.model'; -import { RemoteDataBuildService } from './remote-data-build.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { ITEM } from '../../shared/item.resource-type'; -import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; -import { LinkService } from './link.service'; -import { RequestService } from '../../data/request.service'; -import { UnCacheableObject } from '../../shared/uncacheable-object.model'; -import { RemoteData } from '../../data/remote-data'; -import { Observable, of as observableOf } from 'rxjs'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { take } from 'rxjs/operators'; -import { HALLink } from '../../shared/hal-link.model'; -import { RequestEntryState } from '../../data/request-entry-state.model'; -import { RequestEntry } from '../../data/request-entry.model'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { take } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; -import { fakeAsync, tick } from '@angular/core/testing'; + +import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject, + createPendingRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestService } from '../../data/request.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { Item } from '../../shared/item.model'; +import { ITEM } from '../../shared/item.resource-type'; +import { PageInfo } from '../../shared/page-info.model'; +import { UnCacheableObject } from '../../shared/uncacheable-object.model'; +import { ObjectCacheService } from '../object-cache.service'; +import { LinkService } from './link.service'; +import { RemoteDataBuildService } from './remote-data-build.service'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; @@ -49,7 +66,7 @@ describe('RemoteDataBuildService', () => { linkService = getMockLinkService(); requestService = getMockRequestService(); unCacheableObject = { - foo: 'bar' + foo: 'bar', }; pageInfo = new PageInfo(); selfLink1 = 'https://rest.api/some/object'; @@ -64,31 +81,31 @@ describe('RemoteDataBuildService', () => { 'dc.title': [ { language: 'en_US', - value: 'Item nr 1' - } - ] + value: 'Item nr 1', + }, + ], }, _links: { self: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, }), Object.assign(new Item(), { metadata: { 'dc.title': [ { language: 'en_US', - value: 'Item nr 2' - } - ] + value: 'Item nr 2', + }, + ], }, _links: { self: { - href: selfLink2 - } - } - }) + href: selfLink2, + }, + }, + }), ]; paginatedList = buildPaginatedList(pageInfo, array); normalizedPaginatedList = buildPaginatedList(pageInfo, array, true); @@ -96,43 +113,43 @@ describe('RemoteDataBuildService', () => { paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); entrySuccessCacheable = { request: { - uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487' + uuid: '17820127-0ee5-4ed4-b6da-e654bdff8487', }, state: RequestEntryState.Success, response: { statusCode: 200, payloadLink: { - href: selfLink1 - } - } + href: selfLink1, + }, + }, } as RequestEntry; entrySuccessUnCacheable = { request: { - uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501' + uuid: '0aa5ec06-d6a7-4e73-952e-1e0462bd1501', }, state: RequestEntryState.Success, response: { statusCode: 200, unCacheableObject, - } + }, } as RequestEntry; entrySuccessNoContent = { request: { - uuid: '780a7295-6102-4a43-9775-80f2a4ff673c' + uuid: '780a7295-6102-4a43-9775-80f2a4ff673c', }, state: RequestEntryState.Success, response: { - statusCode: 204 + statusCode: 204, }, } as RequestEntry; entryError = { request: { - uuid: '1609dcbc-8442-4877-966e-864f151cc40c' + uuid: '1609dcbc-8442-4877-966e-864f151cc40c', }, state: RequestEntryState.Error, response: { statusCode: 500, - } + }, } as RequestEntry; requestEntry$ = observableOf(entrySuccessCacheable); linksToFollow = [ @@ -427,8 +444,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: { href: 'payload-link' } - } + payloadLink: { href: 'payload-link' }, + }, }; }); @@ -441,8 +458,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - payloadLink: undefined - } + payloadLink: undefined, + }, }; }); @@ -459,8 +476,8 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { entry = { response: { - unCacheableObject: Object.assign({}) - } + unCacheableObject: Object.assign({}), + }, }; }); @@ -472,7 +489,7 @@ describe('RemoteDataBuildService', () => { describe('when the entry\'s response doesn\'t contain an uncacheable object', () => { beforeEach(() => { entry = { - response: {} + response: {}, }; }); @@ -487,7 +504,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new instance of that type`, () => { const source: any = { type: ITEM, - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -503,7 +520,7 @@ describe('RemoteDataBuildService', () => { it(`should return a new plain JS object`, () => { const source: any = { type: 'foobar', - uuid: 'some-uuid' + uuid: 'some-uuid', }; const result = (service as any).plainObjectToInstance(source); @@ -528,7 +545,7 @@ describe('RemoteDataBuildService', () => { beforeEach(() => { paginatedLinksToFollow = [ followLink('page', {}, ...linksToFollow), - ...linksToFollow + ...linksToFollow, ]; }); describe(`and the given list doesn't have a page property already`, () => { @@ -843,15 +860,15 @@ describe('RemoteDataBuildService', () => { it('should only emit after the callback is done', () => { testScheduler.run(({ cold: tsCold, expectObservable }) => { buildFromRequestUUIDSpy.and.returnValue( - tsCold('-p----s', RDs) + tsCold('-p----s', RDs), ); callback.and.returnValue( - tsCold(' --t', BOOLEAN) + tsCold(' --t', BOOLEAN), ); const done$ = service.buildFromRequestUUIDAndAwait('some-href', callback); expectObservable(done$).toBe( - ' -p------s', RDs // resulting duration between pending & successful includes the callback + ' -p------s', RDs, // resulting duration between pending & successful includes the callback ); }); }); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 075bf3ca0c..e5a6114872 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -5,27 +5,50 @@ import { Observable, of as observableOf, } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged, startWith } from 'rxjs/operators'; -import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; +import { + distinctUntilKeyChanged, + filter, + map, + startWith, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, + isUndefined, +} from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; +import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { RemoteData } from '../../data/remote-data'; import { RequestService } from '../../data/request.service'; -import { ObjectCacheService } from '../object-cache.service'; -import { LinkService } from './link.service'; -import { HALLink } from '../../shared/hal-link.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { getClassForType } from './build-decorators'; -import { HALResource } from '../../shared/hal-resource.model'; -import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; -import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; -import { getResourceTypeValueFor } from '../object-cache.reducer'; -import { hasSucceeded, isStale, RequestEntryState } from '../../data/request-entry-state.model'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/request.operators'; import { RequestEntry } from '../../data/request-entry.model'; +import { + hasSucceeded, + isStale, + RequestEntryState, +} from '../../data/request-entry-state.model'; import { ResponseState } from '../../data/response-state.model'; +import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { + getRequestFromRequestHref, + getRequestFromRequestUUID, +} from '../../shared/request.operators'; +import { getResourceTypeValueFor } from '../object-cache.reducer'; +import { ObjectCacheService } from '../object-cache.service'; +import { getClassForType } from './build-decorators'; +import { LinkService } from './link.service'; @Injectable() export class RemoteDataBuildService { @@ -77,7 +100,7 @@ export class RemoteDataBuildService { } } return [obj]; - }) + }), ); } @@ -151,7 +174,7 @@ export class RemoteDataBuildService { paginatedList.page = page .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return this.linkService.resolveLinks(paginatedList, ...otherLinks); @@ -161,9 +184,10 @@ export class RemoteDataBuildService { } else { // in case the elements of the paginated list were already filled in, because they're UnCacheableObjects paginatedList.page = paginatedList.page + .filter((obj: any) => obj != null) .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => - this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) + this.linkService.resolveLinks(obj, ...pageLink.linksToFollow), ); if (isNotEmpty(otherLinks)) { return observableOf(this.linkService.resolveLinks(paginatedList, ...otherLinks)); @@ -229,7 +253,7 @@ export class RemoteDataBuildService { } else { return [rd]; } - }) + }), ); } @@ -272,12 +296,13 @@ export class RemoteDataBuildService { return isStale(r2.state) ? r1 : r2; } }), - distinctUntilKeyChanged('lastUpdated') ); const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); - return this.toRemoteDataObservable(requestEntry$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$).pipe( + distinctUntilKeyChanged('lastUpdated'), + ); } /** @@ -293,12 +318,12 @@ export class RemoteDataBuildService { toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { return observableCombineLatest([ requestEntry$, - payload$ + payload$, ]).pipe( filter(([entry,payload]: [RequestEntry, T]) => hasValue(entry) && // filter out cases where the state is successful, but the payload isn't yet set - !(hasSucceeded(entry.state) && isUndefined(payload)) + !(hasSucceeded(entry.state) && isUndefined(payload)), ), map(([entry, payload]: [RequestEntry, T]) => { let response = entry.response; @@ -313,9 +338,9 @@ export class RemoteDataBuildService { entry.state, response.errorMessage, payload, - response.statusCode + response.statusCode, ); - }) + }), ); } @@ -406,7 +431,7 @@ export class RemoteDataBuildService { state, errorMessage, payload, - statusCode + statusCode, ); })); } diff --git a/src/app/core/cache/cacheable-object.model.ts b/src/app/core/cache/cacheable-object.model.ts index b7d1609d58..86d041dab7 100644 --- a/src/app/core/cache/cacheable-object.model.ts +++ b/src/app/core/cache/cacheable-object.model.ts @@ -1,6 +1,6 @@ /* tslint:disable:max-classes-per-file */ -import { HALResource } from '../shared/hal-resource.model'; import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; import { TypedObject } from './typed-object.model'; /** diff --git a/src/app/core/cache/models/self-link.model.ts b/src/app/core/cache/models/self-link.model.ts index a87acdd506..903a779495 100644 --- a/src/app/core/cache/models/self-link.model.ts +++ b/src/app/core/cache/models/self-link.model.ts @@ -3,9 +3,9 @@ import { autoserialize } from 'cerialize'; export class SelfLink { @autoserialize - self: string; + self: string; @autoserialize - uuid: string; + uuid: string; } diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index c18a20ffd6..5f8f60e1f1 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -1,8 +1,8 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { type } from '../../shared/ngrx/type'; -import { Operation } from 'fast-json-patch'; import { CacheableObject } from './cacheable-object.model'; /** @@ -15,7 +15,7 @@ export const ObjectCacheActionTypes = { ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'), ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'), - REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS') + REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS'), }; /** diff --git a/src/app/core/cache/object-cache.effects.spec.ts b/src/app/core/cache/object-cache.effects.spec.ts index 3a50a5dbc7..66270be4c2 100644 --- a/src/app/core/cache/object-cache.effects.spec.ts +++ b/src/app/core/cache/object-cache.effects.spec.ts @@ -1,10 +1,14 @@ import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { ObjectCacheEffects } from './object-cache.effects'; -import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + import { StoreActionTypes } from '../../store.actions'; +import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { ObjectCacheEffects } from './object-cache.effects'; describe('ObjectCacheEffects', () => { let cacheEffects: ObjectCacheEffects; diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index fa2bf6f690..0de59a152c 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,6 +1,10 @@ -import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { map } from 'rxjs/operators'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,9 +20,9 @@ export class ObjectCacheEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - fixTimestampsOnRehydrate = createEffect(() => this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), - map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())) + map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())), )); constructor(private actions$: Actions) { diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 919edc8e57..7dda02a0f5 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; import { Operation } from 'fast-json-patch'; + import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, @@ -11,7 +12,6 @@ import { RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; - import { objectCacheReducer } from './object-cache.reducer'; class NullAction extends RemoveFromObjectCacheAction { @@ -54,7 +54,7 @@ describe('objectCacheReducer', () => { type: Item.type, self: selfLink2, foo: 'baz', - _links: { self: { href: selfLink2 } } + _links: { self: { href: selfLink2 } }, }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), @@ -62,8 +62,8 @@ describe('objectCacheReducer', () => { requestUUIDs: [requestUUID2], dependentRequestUUIDs: [requestUUID1], patches: [], - isDirty: false - } + isDirty: false, + }, }; deepFreeze(testState); @@ -126,6 +126,10 @@ describe('objectCacheReducer', () => { deepFreeze(state); objectCacheReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should remove the specified object from the cache in response to the REMOVE action', () => { @@ -149,6 +153,10 @@ describe('objectCacheReducer', () => { const action = new RemoveFromObjectCacheAction(selfLink1); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should set the timestamp of all objects in the cache in response to a RESET_TIMESTAMPS action', () => { @@ -164,16 +172,24 @@ describe('objectCacheReducer', () => { const action = new ResetObjectCacheTimestampsAction(new Date().getTime()); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_PATCH action without affecting the previous state', () => { const action = new AddPatchObjectCacheAction(selfLink1, [{ op: 'replace', path: '/name', - value: 'random string' + value: 'random string', }]); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should when the ADD_PATCH action dispatched', () => { diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index dc3f50db68..631ecd2209 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,18 +1,26 @@ /* eslint-disable max-classes-per-file */ +import { + applyPatch, + Operation, +} from 'fast-json-patch'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { CacheEntry } from './cache-entry'; +import { CacheableObject } from './cacheable-object.model'; import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, ObjectCacheAction, - ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, + ObjectCacheActionTypes, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction, } from './object-cache.actions'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { CacheEntry } from './cache-entry'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { CacheableObject } from './cacheable-object.model'; /** * An interface to represent a JsonPatch @@ -177,8 +185,8 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio dependentRequestUUIDs: existing.dependentRequestUUIDs || [], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], - alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] - } as ObjectCacheEntry + alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks], + } as ObjectCacheEntry, }); } @@ -217,7 +225,7 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject const newState = Object.create(null); Object.keys(state).forEach((key) => { newState[key] = Object.assign({}, state[key], { - timeCompleted: action.payload + timeCompleted: action.payload, }); }); return newState; @@ -241,7 +249,7 @@ function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCach const patches = newState[uuid].patches; newState[uuid] = Object.assign({}, newState[uuid], { patches: [...patches, { operations } as Patch], - isDirty: true + isDirty: true, }); } return newState; @@ -286,8 +294,8 @@ function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDepen ...new Set([ ...newState[href]?.dependentRequestUUIDs || [], ...action.payload.dependentRequestUUIDs, - ]) - ] + ]), + ], }); } @@ -308,7 +316,7 @@ function removeDependentsObjectCacheState(state: ObjectCacheState, action: Remov if (hasValue(newState[href])) { newState[href] = Object.assign({}, newState[href], { - dependentRequestUUIDs: [] + dependentRequestUUIDs: [], }); } diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 6af797be29..3d27f7252c 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,33 +1,44 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; - -import { cold } from 'jasmine-marbles'; -import { Store, StoreModule } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { Operation } from 'fast-json-patch'; -import { empty, of as observableOf } from 'rxjs'; +import { cold } from 'jasmine-marbles'; +import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; +import { + empty, + of as observableOf, +} from 'rxjs'; import { first } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; -import { coreReducers} from '../core.reducers'; +import { storeModuleConfig } from '../../app.reducer'; +import { coreReducers } from '../core.reducers'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { IndexName } from '../index/index-name.model'; +import { HALLink } from '../shared/hal-link.model'; import { Item } from '../shared/item.model'; import { AddDependentsObjectCacheAction, - RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, } from './object-cache.actions'; import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; -import { storeModuleConfig } from '../../app.reducer'; -import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; -import { IndexName } from '../index/index-name.model'; -import { CoreState } from '../core-state.model'; -import { TestScheduler } from 'rxjs/testing'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -69,8 +80,8 @@ describe('ObjectCacheService', () => { type: Item.type, _links: { self: { href: selfLink }, - anotherLink: { href: anotherLink } - } + anotherLink: { href: anotherLink }, + }, }; cacheEntry = { data: objectToCache, @@ -96,8 +107,8 @@ describe('ObjectCacheService', () => { 'cache/syncbuffer': {}, 'cache/object-updates': {}, 'data/request': {}, - 'index': {} - } + 'index': {}, + }, }; } @@ -105,12 +116,12 @@ describe('ObjectCacheService', () => { TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot(coreReducers, storeModuleConfig) + StoreModule.forRoot(coreReducers, storeModuleConfig), ], providers: [ provideMockStore({ initialState }), - { provide: ObjectCacheService, useValue: service } - ] + { provide: ObjectCacheService, useValue: service }, + ], }).compileComponents(); })); @@ -120,7 +131,7 @@ describe('ObjectCacheService', () => { mockStore = store as MockStore; mockStore.setState(initialState); linkServiceStub = { - removeResolvedLinks: (a) => a + removeResolvedLinks: (a) => a, }; spyOn(linkServiceStub, 'removeResolvedLinks').and.callThrough(); spyOn(store, 'dispatch'); @@ -209,7 +220,7 @@ describe('ObjectCacheService', () => { describe('getList', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { const item = Object.assign(new Item(), { - _links: { self: { href: selfLink } } + _links: { self: { href: selfLink } }, }); spyOn(service, 'getObjectByHref').and.returnValue(observableOf(item)); @@ -251,7 +262,7 @@ describe('ObjectCacheService', () => { 'something', 'something-else', 'specific-request', - ] + ], }))); }); @@ -266,7 +277,7 @@ describe('ObjectCacheService', () => { requestUUIDs: [ 'something', 'something-else', - ] + ], }))); }); @@ -292,9 +303,9 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry - } - }) + [selfLink]: cacheEntry, + }, + }), }); mockStore.setState(state); const expected: TestColdObservable = cold('a', { a: cacheEntry }); @@ -310,14 +321,14 @@ describe('ObjectCacheService', () => { const state = Object.assign({}, initialState, { core: Object.assign({}, initialState.core, { 'cache/object': { - [selfLink]: cacheEntry + [selfLink]: cacheEntry, }, 'index': { 'object/alt-link-to-self-link': { - [anotherLink]: selfLink - } - } - }) + [anotherLink]: selfLink, + }, + }, + }), }); mockStore.setState(state); (service as any).getByAlternativeLink(anotherLink).subscribe(); @@ -335,8 +346,8 @@ describe('ObjectCacheService', () => { it('isDirty should return true when the patches list in the cache entry is not empty', () => { cacheEntry.patches = [ { - operations: operations - } as Patch + operations: operations, + } as Patch, ]; const result = (service as any).isDirty(cacheEntry); expect(result).toBe(true); @@ -371,9 +382,9 @@ describe('ObjectCacheService', () => { [anotherLink]: selfLink, ['objectWithoutDependentsAlt']: 'objectWithoutDependents', ['objectWithDependentsAlt']: 'objectWithDependents', - } - } - }) + }, + }, + }), }); mockStore.setState(state); }); @@ -421,11 +432,11 @@ describe('ObjectCacheService', () => { testScheduler.run(({ cold: tsCold, flush }) => { const href$ = tsCold('--y-n-n', { y: selfLink, - n: 'NOPE' + n: 'NOPE', }); const dependsOnHref$ = tsCold('-y-n-n', { y: 'objectWithoutDependents', - n: 'NOPE' + n: 'NOPE', }); service.addDependency(href$, dependsOnHref$); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 9ca0216210..09ea6450bb 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,32 +1,68 @@ import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { applyPatch, Operation } from 'fast-json-patch'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { + applyPatch, + Operation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + mergeMap, + switchMap, + take, +} from 'rxjs/operators'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core-state.model'; +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; import { RestRequestMethod } from '../data/rest-request-method'; -import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; +import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { + selfLinkFromAlternativeLinkSelector, + selfLinkFromUuidSelector, +} from '../index/index.selectors'; +import { IndexName } from '../index/index-name.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; - -import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; -import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { HALLink } from '../shared/hal-link.model'; import { CacheableObject } from './cacheable-object.model'; -import { IndexName } from '../index/index-name.model'; +import { + AddDependentsObjectCacheAction, + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, + RemoveFromObjectCacheAction, +} from './object-cache.actions'; +import { + ObjectCacheEntry, + ObjectCacheState, +} from './object-cache.reducer'; +import { AddToSSBAction } from './server-sync-buffer.actions'; /** * The base selector function to select the object cache in the store */ const objectCacheSelector = createSelector( coreSelector, - (state: CoreState) => state['cache/object'] + (state: CoreState) => state['cache/object'], ); /** @@ -46,7 +82,7 @@ const entryFromSelfLinkSelector = export class ObjectCacheService { constructor( private store: Store, - private linkService: LinkService + private linkService: LinkService, ) { } @@ -82,12 +118,12 @@ export class ObjectCacheService { const cacheEntry$ = this.getByHref(href); const altLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => entry.alternativeLinks), take(1)); const childLinks$ = cacheEntry$.pipe(map((entry: ObjectCacheEntry) => { - return Object - .entries(entry.data._links) - .filter(([key, value]: [string, HALLink]) => key !== 'self') - .map(([key, value]: [string, HALLink]) => value.href); - }), - take(1) + return Object + .entries(entry.data._links) + .filter(([key, value]: [string, HALLink]) => key !== 'self') + .map(([key, value]: [string, HALLink]) => value.href); + }), + take(1), ); this.removeLinksFromAlternativeLinkIndex(altLinks$); this.removeLinksFromAlternativeLinkIndex(childLinks$); @@ -96,8 +132,8 @@ export class ObjectCacheService { private removeLinksFromAlternativeLinkIndex(links$: Observable) { links$.subscribe((links: string[]) => links.forEach((link: string) => { - this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); - } + this.store.dispatch(new RemoveFromIndexBySubstringAction(IndexName.ALTERNATIVE_OBJECT_LINK, link)); + }, )); } @@ -113,8 +149,8 @@ export class ObjectCacheService { Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getObjectByHref(selfLink) - ) + mergeMap((selfLink: string) => this.getObjectByHref(selfLink), + ), ); } @@ -129,14 +165,14 @@ export class ObjectCacheService { getObjectByHref(href: string): Observable { return this.getByHref(href).pipe( map((entry: ObjectCacheEntry) => { - if (isNotEmpty(entry.patches)) { - const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); - const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; - return Object.assign({}, entry, { data: patchedData }); - } else { - return entry; - } + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } else { + return entry; } + }, ), map((entry: ObjectCacheEntry) => { const type: GenericConstructor = getClassForType((entry.data as any).type); @@ -144,7 +180,7 @@ export class ObjectCacheService { throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); } return Object.assign(new type(), entry.data) as T; - }) + }), ); } @@ -162,13 +198,13 @@ export class ObjectCacheService { this.getBySelfLink(href), ]).pipe( map((results: ObjectCacheEntry[]) => results.find((entry: ObjectCacheEntry) => hasValue(entry))), - filter((entry: ObjectCacheEntry) => hasValue(entry)) + filter((entry: ObjectCacheEntry) => hasValue(entry)), ); } private getBySelfLink(selfLink: string): Observable { return this.store.pipe( - select(entryFromSelfLinkSelector(selfLink)) + select(entryFromSelfLinkSelector(selfLink)), ); } @@ -204,7 +240,7 @@ export class ObjectCacheService { getRequestUUIDByObjectUUID(uuid: string): Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)) + mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)), ); } @@ -232,7 +268,7 @@ export class ObjectCacheService { return observableOf([]); } else { return observableCombineLatest( - selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)) + selfLinks.map((selfLink: string) => this.getObjectByHref(selfLink)), ); } } @@ -252,7 +288,7 @@ export class ObjectCacheService { /* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/ this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - take(1) + take(1), ).subscribe((selfLink: string) => result = this.hasByHref(selfLink)); return result; @@ -290,9 +326,9 @@ export class ObjectCacheService { hasByHref$(href: string): Observable { return observableCombineLatest( this.getBySelfLink(href), - this.getByAlternativeLink(href) + this.getByAlternativeLink(href), ).pipe( - map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))) + map((entries: ObjectCacheEntry[]) => entries.some((entry) => hasValue(entry))), ); } @@ -358,7 +394,7 @@ export class ObjectCacheService { observableCombineLatest([ href$, dependsOnHref$.pipe( - switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)), ), ]).pipe( switchMap(([href, dependsOnSelfLink]: [string, string]) => { @@ -373,7 +409,7 @@ export class ObjectCacheService { this.getByHref(href).pipe( // only add the latest request to keep dependency index from growing indefinitely map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), - ) + ), ]); }), take(1), diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 197bf130fb..9a09a49bc8 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,10 +1,10 @@ /* eslint-disable max-classes-per-file */ -import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; +import { RequestError } from '../data/request-error.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALLink } from '../shared/hal-link.model'; +import { PageInfo } from '../shared/page-info.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; -import { RequestError } from '../data/request-error.model'; export class RestResponse { public toCache = true; @@ -13,7 +13,7 @@ export class RestResponse { constructor( public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { } } @@ -29,7 +29,7 @@ export class DSOSuccessResponse extends RestResponse { public resourceSelfLinks: string[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -43,7 +43,7 @@ export class EndpointMapSuccessResponse extends RestResponse { constructor( public endpointMap: EndpointMap, public statusCode: number, - public statusText: string + public statusText: string, ) { super(true, statusCode, statusText); } @@ -64,7 +64,7 @@ export class ConfigSuccessResponse extends RestResponse { public configDefinition: ConfigObject, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -78,7 +78,7 @@ export class TokenResponse extends RestResponse { public token: string, public isSuccessful: boolean, public statusCode: number, - public statusText: string + public statusText: string, ) { super(isSuccessful, statusCode, statusText); } @@ -89,7 +89,7 @@ export class PostPatchSuccessResponse extends RestResponse { public dataDefinition: any, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -100,7 +100,7 @@ export class EpersonSuccessResponse extends RestResponse { public epersonDefinition: DSpaceObject[], public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -112,7 +112,7 @@ export class MessageResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -124,7 +124,7 @@ export class TaskResponse extends RestResponse { constructor( public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } @@ -135,7 +135,7 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { public filterQuery: string, public statusCode: number, public statusText: string, - public pageInfo?: PageInfo + public pageInfo?: PageInfo, ) { super(true, statusCode, statusText); } diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 833c6b580f..889b3b7454 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,12 +1,22 @@ import { TestBed } from '@angular/core/testing'; - import { provideMockActions } from '@ngrx/effects/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { storeModuleConfig } from '../../app.reducer'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { StoreMock } from '../../shared/testing/store.mock'; import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; @@ -16,11 +26,9 @@ import { ObjectCacheService } from './object-cache.service'; import { CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; -import { storeModuleConfig } from '../../app.reducer'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; describe('ServerSyncBufferEffects', () => { let ssbEffects: ServerSyncBufferEffects; @@ -32,9 +40,9 @@ describe('ServerSyncBufferEffects', () => { autoSync: { timePerMethod: {}, - defaultTime: 0 - } - } + defaultTime: 0, + }, + }, }; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; let store; @@ -52,21 +60,21 @@ describe('ServerSyncBufferEffects', () => { provide: ObjectCacheService, useValue: { getObjectBySelfLink: (link) => { const object = Object.assign(new DSpaceObject(), { - _links: { self: { href: link } } + _links: { self: { href: link } }, }); return observableOf(object); }, getByHref: (link) => { const object = Object.assign(new DSpaceObject(), { _links: { - self: { href: link } - } + self: { href: link }, + }, }); return observableOf(object); - } - } + }, + }, }, - { provide: Store, useClass: StoreMock } + { provide: Store, useClass: StoreMock }, // other providers ], }); @@ -88,12 +96,12 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.ADD, - payload: { href: selfLink, method: RestRequestMethod.PUT } - } + payload: { href: selfLink, method: RestRequestMethod.PUT }, + }, }); expectObservable(ssbEffects.setTimeoutForServerSync).toBe('b', { - b: new CommitSSBAction(RestRequestMethod.PUT) + b: new CommitSSBAction(RestRequestMethod.PUT), }); }); }); @@ -108,8 +116,8 @@ describe('ServerSyncBufferEffects', () => { (state as any).core['cache/syncbuffer'] = { buffer: [{ href: selfLink, - method: RestRequestMethod.PATCH - }] + method: RestRequestMethod.PATCH, + }], }; }); }); @@ -117,13 +125,13 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: RestRequestMethod.PATCH - } + payload: RestRequestMethod.PATCH, + }, }); const expected = cold('(bc)', { b: new ApplyPatchObjectCacheAction(selfLink), - c: new EmptySSBAction(RestRequestMethod.PATCH) + c: new EmptySSBAction(RestRequestMethod.PATCH), }); expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); @@ -136,7 +144,7 @@ describe('ServerSyncBufferEffects', () => { .subscribe((state) => { (state as any).core = Object({}); (state as any).core['cache/syncbuffer'] = { - buffer: [] + buffer: [], }; }); }); @@ -145,8 +153,8 @@ describe('ServerSyncBufferEffects', () => { actions = hot('a', { a: { type: ServerSyncBufferActionTypes.COMMIT, - payload: { method: RestRequestMethod.PATCH } - } + payload: { method: RestRequestMethod.PATCH }, + }, }); const expected = cold('b', { b: new NoOpAction() }); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 9571d4af5b..6f346d5bb3 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,27 +1,55 @@ -import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + Action, + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + delay, + exhaustMap, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { environment } from '../../../environments/environment'; +import { + hasValue, + isNotEmpty, + isNotUndefined, +} from '../../shared/empty.util'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { PatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { ObjectCacheEntry } from './object-cache.reducer'; +import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction, CommitSSBAction, EmptySSBAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { PatchRequest } from '../data/request.models'; -import { ObjectCacheService } from './object-cache.service'; -import { ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { environment } from '../../../environments/environment'; -import { ObjectCacheEntry } from './object-cache.reducer'; -import { Operation } from 'fast-json-patch'; -import { NoOpAction } from '../../shared/ngrx/no-op.action'; -import { CoreState } from '../core-state.model'; +import { + ServerSyncBufferEntry, + ServerSyncBufferState, +} from './server-sync-buffer.reducer'; @Injectable() export class ServerSyncBufferEffects { @@ -32,7 +60,7 @@ export class ServerSyncBufferEffects { * Then dispatch a CommitSSBAction * When the delay is running, no new AddToSSBActions are processed in this effect */ - setTimeoutForServerSync = createEffect(() => this.actions$ + setTimeoutForServerSync = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.ADD), exhaustMap((action: AddToSSBAction) => { @@ -41,7 +69,7 @@ export class ServerSyncBufferEffects { return observableOf(new CommitSSBAction(action.payload.method)).pipe( delay(timeoutInSeconds * 1000), ); - }) + }), )); /** @@ -50,7 +78,7 @@ export class ServerSyncBufferEffects { * When the list of actions is not empty, also dispatch an EmptySSBAction * When the list is empty dispatch a NO_ACTION placeholder action */ - commitServerSyncBuffer = createEffect(() => this.actions$ + commitServerSyncBuffer = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.COMMIT), switchMap((action: CommitSSBAction) => { @@ -78,14 +106,14 @@ export class ServerSyncBufferEffects { /* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */ if (isNotEmpty(actions) && isNotUndefined(actions[0])) { return observableCombineLatest(...actions).pipe( - switchMap((array) => [...array, new EmptySSBAction(action.payload)]) - ); + switchMap((array) => [...array, new EmptySSBAction(action.payload)]), + ); } else { return observableOf(new NoOpAction()); } - }) + }), ); - }) + }), )); /** @@ -96,7 +124,7 @@ export class ServerSyncBufferEffects { */ private applyPatch(href: string): Observable { const patchObject = this.objectCache.getByHref(href).pipe( - take(1) + take(1), ); return patchObject.pipe( @@ -108,7 +136,7 @@ export class ServerSyncBufferEffects { } } return new ApplyPatchObjectCacheAction(href); - }) + }), ); } diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts index 51ba010c1e..d986581ce2 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.spec.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -1,9 +1,13 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; -import { RemoveFromObjectCacheAction } from './object-cache.actions'; -import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; + import { RestRequestMethod } from '../data/rest-request-method'; -import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions'; +import { RemoveFromObjectCacheAction } from './object-cache.actions'; +import { + AddToSSBAction, + EmptySSBAction, +} from './server-sync-buffer.actions'; +import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -27,8 +31,8 @@ describe('serverSyncBufferReducer', () => { { href: selfLink2, method: RestRequestMethod.GET, - } - ] + }, + ], }; const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0'; @@ -52,12 +56,20 @@ describe('serverSyncBufferReducer', () => { const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the EMPTY action without affecting the previous state', () => { const action = new EmptySSBAction(); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { @@ -79,7 +91,7 @@ describe('serverSyncBufferReducer', () => { // testState has already been frozen above const newState = serverSyncBufferReducer(testState, action); expect(newState.buffer).toContain({ - href: newSelfLink, method: RestRequestMethod.PUT + href: newSelfLink, method: RestRequestMethod.PUT, }) ; }); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index 3e8944aa73..f1ae894315 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -1,11 +1,14 @@ -import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; +import { RestRequestMethod } from '../data/rest-request-method'; import { AddToSSBAction, EmptySSBAction, ServerSyncBufferAction, - ServerSyncBufferActionTypes + ServerSyncBufferActionTypes, } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; /** * An entry in the ServerSyncBufferState @@ -86,9 +89,9 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi * the new state, with a new entry added to the buffer */ function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { - let newBuffer = []; - if (hasValue(action.payload)) { - newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); - } - return Object.assign({}, state, { buffer: newBuffer }); + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); } diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.html b/src/app/core/coar-notify/notify-info/notify-info.component.html new file mode 100644 index 0000000000..3370f83d03 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.html @@ -0,0 +1,18 @@ +
+ + {{ 'coar-notify-support.title' | translate }} + + +

{{ 'coar-notify-support.title' | translate }}

+

+ +

{{ 'coar-notify-support.ldn-inbox.title' | translate }}

+

+ +

{{ 'coar-notify-support.message-moderation.title' | translate }}

+

+ {{ 'coar-notify-support.message-moderation.content' | translate }} + {{ 'coar-notify-support.message-moderation.feedback-form' | translate }} +

+ +
diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss b/src/app/core/coar-notify/notify-info/notify-info.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss rename to src/app/core/coar-notify/notify-info/notify-info.component.scss diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts new file mode 100644 index 0000000000..e91b065dfc --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts @@ -0,0 +1,40 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { NotifyInfoComponent } from './notify-info.component'; +import { NotifyInfoService } from './notify-info.service'; + +describe('NotifyInfoComponent', () => { + let component: NotifyInfoComponent; + let fixture: ComponentFixture; + let notifyInfoServiceSpy: any; + + beforeEach(async () => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['getCoarLdnLocalInboxUrls']); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ NotifyInfoComponent ], + providers: [ + { provide: NotifyInfoService, useValue: notifyInfoServiceSpy }, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotifyInfoComponent); + component = fixture.componentInstance; + component.coarRestApiUrl = of([]); + spyOn(component, 'generateCoarRestApiLinksHTML').and.returnValue(of('')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.ts b/src/app/core/coar-notify/notify-info/notify-info.component.ts new file mode 100644 index 0000000000..bfa268440b --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.component.ts @@ -0,0 +1,47 @@ +import { + Component, + OnInit, +} from '@angular/core'; +import { + map, + Observable, + of, +} from 'rxjs'; + +import { NotifyInfoService } from './notify-info.service'; + +@Component({ + selector: 'ds-notify-info', + templateUrl: './notify-info.component.html', + styleUrls: ['./notify-info.component.scss'], +}) +/** + * Component for displaying COAR notification information. + */ +export class NotifyInfoComponent implements OnInit { + /** + * Observable containing the COAR REST INBOX API URLs. + */ + coarRestApiUrl: Observable = of([]); + + constructor(private notifyInfoService: NotifyInfoService) {} + + ngOnInit() { + this.coarRestApiUrl = this.notifyInfoService.getCoarLdnLocalInboxUrls(); + } + + /** + * Generates HTML code for COAR REST API links. + * @returns An Observable that emits the generated HTML code. + */ + generateCoarRestApiLinksHTML() { + return this.coarRestApiUrl.pipe( + // transform the data into HTML + map((urls) => { + return urls.map(url => ` + ${url} + `).join(','); + }), + ); + } +} diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts new file mode 100644 index 0000000000..7c8cc3f320 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; + +import { NotifyInfoGuard } from './notify-info.guard'; +import { NotifyInfoService } from './notify-info.service'; + +describe('NotifyInfoGuard', () => { + let guard: NotifyInfoGuard; + let notifyInfoServiceSpy: any; + let router: any; + + beforeEach(() => { + notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['isCoarConfigEnabled']); + router = jasmine.createSpyObj('Router', ['parseUrl']); + TestBed.configureTestingModule({ + providers: [ + NotifyInfoGuard, + { provide: NotifyInfoService, useValue: notifyInfoServiceSpy }, + { provide: Router, useValue: router }, + ], + }); + guard = TestBed.inject(NotifyInfoGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + + it('should return true if COAR config is enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true)); + + guard.canActivate(null, null).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should call parseUrl method of Router if COAR config is not enabled', (done) => { + notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(false)); + router.parseUrl.and.returnValue(of('/404')); + + guard.canActivate(null, null).subscribe(() => { + expect(router.parseUrl).toHaveBeenCalledWith('/404'); + done(); + }); + }); + +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.ts new file mode 100644 index 0000000000..91f3bf6cde --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.guard.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { NotifyInfoService } from './notify-info.service'; + +@Injectable({ + providedIn: 'root', +}) +export class NotifyInfoGuard implements CanActivate { + constructor( + private notifyInfoService: NotifyInfoService, + private router: Router, + ) {} + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + ): Observable { + return this.notifyInfoService.isCoarConfigEnabled().pipe( + map(coarLdnEnabled => { + if (coarLdnEnabled) { + return true; + } else { + return this.router.parseUrl('/404'); + } + }), + ); + } +} diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts new file mode 100644 index 0000000000..d32ad729dd --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { NotifyInfoService } from './notify-info.service'; + +describe('NotifyInfoService', () => { + let service: NotifyInfoService; + let configurationDataService: any; + let authorizationDataService: any; + beforeEach(() => { + authorizationDataService = { + isAuthorized: jasmine.createSpy('isAuthorized').and.returnValue(of(true)), + }; + configurationDataService = { + findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(of({})), + }; + TestBed.configureTestingModule({ + providers: [ + NotifyInfoService, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: AuthorizationDataService, useValue: authorizationDataService }, + ], + }); + service = TestBed.inject(NotifyInfoService); + authorizationDataService = TestBed.inject(AuthorizationDataService); + configurationDataService = TestBed.inject(ConfigurationDataService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should retrieve and map coar configuration', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['true'] })); + + service.isCoarConfigEnabled().subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it('should retrieve and map LDN local inbox URLs', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['inbox1', 'inbox2'] })); + + service.getCoarLdnLocalInboxUrls().subscribe((result) => { + expect(result).toEqual(['inbox1', 'inbox2']); + done(); + }); + }); + + it('should return the inbox relation link', () => { + expect(service.getInboxRelationLink()).toBe('http://www.w3.org/ns/ldp#inbox'); + }); +}); diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.ts b/src/app/core/coar-notify/notify-info/notify-info.service.ts new file mode 100644 index 0000000000..a70a5d5cc0 --- /dev/null +++ b/src/app/core/coar-notify/notify-info/notify-info.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, +} from 'rxjs'; + +import { ConfigurationDataService } from '../../data/configuration-data.service'; +import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../data/feature-authorization/feature-id'; +import { ConfigurationProperty } from '../../shared/configuration-property.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../shared/operators'; + +/** + * Service to check COAR availability and LDN services information for the COAR Notify functionalities + */ +@Injectable({ + providedIn: 'root', +}) +export class NotifyInfoService { + + /** + * The relation link for the inbox + */ + private _inboxRelationLink = 'http://www.w3.org/ns/ldp#inbox'; + + constructor( + private configService: ConfigurationDataService, + protected authorizationService: AuthorizationDataService, + ) {} + + isCoarConfigEnabled(): Observable { + return this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled); + } + + /** + * Get the url of the local inbox from the REST configuration + * @returns the url of the local inbox + */ + getCoarLdnLocalInboxUrls(): Observable { + return this.configService.findByPropertyName('ldn.notify.inbox').pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + map((response: ConfigurationProperty) => { + return response.values; + }), + ); + } + + /** + * Method to get the relation link for the inbox + * @returns the relation link for the inbox + */ + getInboxRelationLink(): string { + return this._inboxRelationLink; + } +} diff --git a/src/app/core/config/bulk-access-config-data.service.ts b/src/app/core/config/bulk-access-config-data.service.ts index 28b4029ea2..edae249a8f 100644 --- a/src/app/core/config/bulk-access-config-data.service.ts +++ b/src/app/core/config/bulk-access-config-data.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { ConfigDataService } from './config-data.service'; import { dataService } from '../data/base/data-service.decorator'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; import { BULK_ACCESS_CONDITION_OPTIONS } from './models/config-type'; /** diff --git a/src/app/core/config/config-data.service.spec.ts b/src/app/core/config/config-data.service.spec.ts index 38340d1ad5..a9979f1bb5 100644 --- a/src/app/core/config/config-data.service.spec.ts +++ b/src/app/core/config/config-data.service.spec.ts @@ -1,15 +1,16 @@ import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { GetRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { FindListOptions } from '../data/find-list-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts index 58b023e62c..51ca655921 100644 --- a/src/app/core/config/config-data.service.ts +++ b/src/app/core/config/config-data.service.ts @@ -1,11 +1,11 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfigObject } from './models/config.model'; -import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { RemoteData } from '../data/remote-data'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { ConfigObject } from './models/config.model'; /** * Abstract data service to retrieve configuration objects from the REST server. diff --git a/src/app/core/config/models/bulk-access-condition-options.model.ts b/src/app/core/config/models/bulk-access-condition-options.model.ts index d84e14b95d..c491343852 100644 --- a/src/app/core/config/models/bulk-access-condition-options.model.ts +++ b/src/app/core/config/models/bulk-access-condition-options.model.ts @@ -1,8 +1,13 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { + autoserialize, + autoserializeAs, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { ResourceType } from '../../shared/resource-type'; import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { BULK_ACCESS_CONDITION_OPTIONS } from './config-type'; @@ -20,19 +25,19 @@ export class BulkAccessConditionOptions extends ConfigObject { */ @excludeFromEquals @autoserialize - type: ResourceType; + type: ResourceType; @autoserializeAs(String, 'name') - uuid: string; + uuid: string; @autoserialize - id: string; + id: string; @autoserialize - itemAccessConditionOptions: AccessesConditionOption[]; + itemAccessConditionOptions: AccessesConditionOption[]; @autoserialize - bitstreamAccessConditionOptions: AccessesConditionOption[]; + bitstreamAccessConditionOptions: AccessesConditionOption[]; _links: { self: HALLink }; } diff --git a/src/app/core/config/models/config-accesses-conditions-options.model.ts b/src/app/core/config/models/config-accesses-conditions-options.model.ts index 244b501908..64199be0eb 100644 --- a/src/app/core/config/models/config-accesses-conditions-options.model.ts +++ b/src/app/core/config/models/config-accesses-conditions-options.model.ts @@ -3,43 +3,43 @@ */ export class AccessesConditionOption { - /** + /** * The name for this Access Condition */ - name: string; + name: string; - /** + /** * The groupName for this Access Condition */ - groupName: string; + groupName: string; - /** + /** * A boolean representing if this Access Condition has a start date */ - hasStartDate: boolean; + hasStartDate: boolean; - /** + /** * A boolean representing if this Access Condition has an end date */ - hasEndDate: boolean; + hasEndDate: boolean; - /** + /** * Maximum value of the start date */ - endDateLimit?: string; + endDateLimit?: string; - /** + /** * Maximum value of the end date */ - startDateLimit?: string; + startDateLimit?: string; - /** + /** * Maximum value of the start date */ - maxStartDate?: string; + maxStartDate?: string; - /** + /** * Maximum value of the end date */ - maxEndDate?: string; + maxEndDate?: string; } diff --git a/src/app/core/config/models/config-submission-access.model.ts b/src/app/core/config/models/config-submission-access.model.ts index 7db96acf2b..4617dc4719 100644 --- a/src/app/core/config/models/config-submission-access.model.ts +++ b/src/app/core/config/models/config-submission-access.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessesConditionOption } from './config-accesses-conditions-options.model'; import { SUBMISSION_ACCESSES_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; /** * Class for the configuration describing the item accesses condition @@ -17,25 +22,25 @@ export class SubmissionAccessModel extends ConfigObject { * A list of available item access conditions */ @autoserialize - accessConditionOptions: AccessesConditionOption[]; + accessConditionOptions: AccessesConditionOption[]; /** * Boolean that indicates whether the current item must be findable via search or browse. */ @autoserialize - discoverable: boolean; + discoverable: boolean; /** * Boolean that indicates whether or not the user can change the discoverable flag. */ @autoserialize - canChangeDiscoverable: boolean; + canChangeDiscoverable: boolean; /** * The links to all related resources returned by the rest api. */ @deserialize - _links: { + _links: { self: HALLink }; diff --git a/src/app/core/config/models/config-submission-accesses.model.ts b/src/app/core/config/models/config-submission-accesses.model.ts index 3f8004928d..b3c097cc8a 100644 --- a/src/app/core/config/models/config-submission-accesses.model.ts +++ b/src/app/core/config/models/config-submission-accesses.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_ACCESSES_TYPE } from './config-type'; import { SubmissionAccessModel } from './config-submission-access.model'; +import { SUBMISSION_ACCESSES_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionAccessModel) diff --git a/src/app/core/config/models/config-submission-definition.model.ts b/src/app/core/config/models/config-submission-definition.model.ts index b07917e032..2d6b1ad604 100644 --- a/src/app/core/config/models/config-submission-definition.model.ts +++ b/src/app/core/config/models/config-submission-definition.model.ts @@ -1,9 +1,14 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { PaginatedList } from '../../data/paginated-list.model'; import { HALLink } from '../../shared/hal-link.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; import { ConfigObject } from './config.model'; +import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_DEFINITION_TYPE } from './config-type'; /** @@ -18,20 +23,20 @@ export class SubmissionDefinitionModel extends ConfigObject { * A boolean representing if this submission definition is the default or not */ @autoserialize - isDefault: boolean; + isDefault: boolean; /** * A list of SubmissionSectionModel that are present in this submission definition */ // TODO refactor using remotedata @deserialize - sections: PaginatedList; + sections: PaginatedList; /** * The links to all related resources returned by the rest api. */ @deserialize - _links: { + _links: { self: HALLink, collections: HALLink, sections: HALLink diff --git a/src/app/core/config/models/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts index 08f1ef17bb..790334da9b 100644 --- a/src/app/core/config/models/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionDefinitionModel } from './config-submission-definition.model'; import { SUBMISSION_DEFINITIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-form.model.ts b/src/app/core/config/models/config-submission-form.model.ts index 90f94882bd..f6011adc76 100644 --- a/src/app/core/config/models/config-submission-form.model.ts +++ b/src/app/core/config/models/config-submission-form.model.ts @@ -1,7 +1,11 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + inheritSerialization, +} from 'cerialize'; + +import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { typedObject } from '../../cache/builders/build-decorators'; import { ConfigObject } from './config.model'; -import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { SUBMISSION_FORM_TYPE } from './config-type'; /** @@ -23,5 +27,5 @@ export class SubmissionFormModel extends ConfigObject { * An array of [FormRowModel] that are present in this form */ @autoserialize - rows: FormRowModel[]; + rows: FormRowModel[]; } diff --git a/src/app/core/config/models/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts index 506905d88c..4cf71d85d9 100644 --- a/src/app/core/config/models/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionFormModel } from './config-submission-form.model'; import { SUBMISSION_FORMS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index bdc884dfa4..0d4ae9aa10 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -1,4 +1,9 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; + import { SectionsType } from '../../../submission/sections/sections-type'; import { typedObject } from '../../cache/builders/build-decorators'; import { HALLink } from '../../shared/hal-link.model'; @@ -22,31 +27,31 @@ export class SubmissionSectionModel extends ConfigObject { * The header for this section */ @autoserialize - header: string; + header: string; /** * A boolean representing if this submission section is the mandatory or not */ @autoserialize - mandatory: boolean; + mandatory: boolean; /** * A string representing the kind of section object */ @autoserialize - sectionType: SectionsType; + sectionType: SectionsType; /** * The [SubmissionSectionVisibility] object for this section */ @autoserialize - visibility: SubmissionSectionVisibility; + visibility: SubmissionSectionVisibility; /** * The {@link HALLink}s for this SubmissionSectionModel */ @deserialize - _links: { + _links: { self: HALLink; config: HALLink; }; diff --git a/src/app/core/config/models/config-submission-sections.model.ts b/src/app/core/config/models/config-submission-sections.model.ts index 423ea99b1e..86894b6e44 100644 --- a/src/app/core/config/models/config-submission-sections.model.ts +++ b/src/app/core/config/models/config-submission-sections.model.ts @@ -1,4 +1,5 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionSectionModel } from './config-submission-section.model'; import { SUBMISSION_SECTIONS_TYPE } from './config-type'; diff --git a/src/app/core/config/models/config-submission-upload.model.ts b/src/app/core/config/models/config-submission-upload.model.ts index f6897da2e3..edc4626f83 100644 --- a/src/app/core/config/models/config-submission-upload.model.ts +++ b/src/app/core/config/models/config-submission-upload.model.ts @@ -1,12 +1,23 @@ -import { autoserialize, inheritSerialization, deserialize } from 'cerialize'; -import { typedObject, link } from '../../cache/builders/build-decorators'; +import { + autoserialize, + deserialize, + inheritSerialization, +} from 'cerialize'; +import { Observable } from 'rxjs'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { RemoteData } from '../../data/remote-data'; +import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { AccessConditionOption } from './config-access-condition-option.model'; import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SUBMISSION_UPLOAD_TYPE, SUBMISSION_FORMS_TYPE } from './config-type'; -import { HALLink } from '../../shared/hal-link.model'; -import { RemoteData } from '../../data/remote-data'; -import { Observable } from 'rxjs'; +import { + SUBMISSION_FORMS_TYPE, + SUBMISSION_UPLOAD_TYPE, +} from './config-type'; @typedObject @inheritSerialization(ConfigObject) @@ -16,22 +27,22 @@ export class SubmissionUploadModel extends ConfigObject { * A list of available bitstream access conditions */ @autoserialize - accessConditionOptions: AccessConditionOption[]; + accessConditionOptions: AccessConditionOption[]; /** * An object representing the configuration describing the bitstream metadata form */ @link(SUBMISSION_FORMS_TYPE) - metadata?: Observable>; + metadata?: Observable>; @autoserialize - required: boolean; + required: boolean; @autoserialize - maxSize: number; + maxSize: number; @deserialize - _links: { + _links: { metadata: HALLink self: HALLink }; diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts index 8fb7dc66b9..235cdd31a1 100644 --- a/src/app/core/config/models/config-submission-uploads.model.ts +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -1,7 +1,8 @@ import { inheritSerialization } from 'cerialize'; + import { typedObject } from '../../cache/builders/build-decorators'; -import { SUBMISSION_UPLOADS_TYPE } from './config-type'; import { SubmissionUploadModel } from './config-submission-upload.model'; +import { SUBMISSION_UPLOADS_TYPE } from './config-type'; @typedObject @inheritSerialization(SubmissionUploadModel) diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 170aa334ed..c1db44e891 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,8 +1,12 @@ -import { autoserialize, deserialize } from 'cerialize'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { CacheableObject } from '../../cache/cacheable-object.model'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { CacheableObject } from '../../cache/cacheable-object.model'; export abstract class ConfigObject implements CacheableObject { @@ -23,13 +27,13 @@ export abstract class ConfigObject implements CacheableObject { */ @excludeFromEquals @autoserialize - type: ResourceType; + type: ResourceType; /** * The links to all related resources returned by the rest api. */ @deserialize - _links: { + _links: { self: HALLink, [name: string]: HALLink }; diff --git a/src/app/core/config/submission-accesses-config-data.service.ts b/src/app/core/config/submission-accesses-config-data.service.ts index d2da0fce42..16986405fd 100644 --- a/src/app/core/config/submission-accesses-config-data.service.ts +++ b/src/app/core/config/submission-accesses-config-data.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +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 { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionAccessesModel } from './models/config-submission-accesses.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; +import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. diff --git a/src/app/core/config/submission-forms-config-data.service.ts b/src/app/core/config/submission-forms-config-data.service.ts index f4c0690685..fb8e0e60da 100644 --- a/src/app/core/config/submission-forms-config-data.service.ts +++ b/src/app/core/config/submission-forms-config-data.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigObject } from './models/config.model'; -import { SUBMISSION_FORMS_TYPE } from './models/config-type'; -import { SubmissionFormsModel } from './models/config-submission-forms.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../data/base/data-service.decorator'; +import { RemoteData } from '../data/remote-data'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigDataService } from './config-data.service'; +import { ConfigObject } from './models/config.model'; +import { SubmissionFormsModel } from './models/config-submission-forms.model'; +import { SUBMISSION_FORMS_TYPE } from './models/config-type'; /** * Data service to retrieve submission form configuration objects from the REST server. diff --git a/src/app/core/config/submission-uploads-config-data.service.ts b/src/app/core/config/submission-uploads-config-data.service.ts index 8f838352a9..b98c8b4f91 100644 --- a/src/app/core/config/submission-uploads-config-data.service.ts +++ b/src/app/core/config/submission-uploads-config-data.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { ConfigDataService } from './config-data.service'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +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 { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ConfigDataService } from './config-data.service'; import { ConfigObject } from './models/config.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../data/base/data-service.decorator'; +import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts index b8211fdb55..2128901754 100644 --- a/src/app/core/core-state.model.ts +++ b/src/app/core/core-state.model.ts @@ -1,16 +1,14 @@ -import { - BitstreamFormatRegistryState -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { BitstreamFormatRegistryState } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { AuthState } from './auth/auth.reducer'; import { ObjectCacheState } from './cache/object-cache.reducer'; import { ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; import { ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; +import { RequestState } from './data/request-state.model'; import { HistoryState } from './history/history.reducer'; import { MetaIndexState } from './index/index.reducer'; -import { AuthState } from './auth/auth.reducer'; import { JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { MetaTagState } from './metadata/meta-tag.reducer'; import { RouteState } from './services/route.reducer'; -import { RequestState } from './data/request-state.model'; /** * The core sub-state in the NgRx store diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index b569df290d..5af2fe580a 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,12 +1,13 @@ -import { ObjectCacheEffects } from './cache/object-cache.effects'; -import { UUIDIndexEffects } from './index/index.effects'; -import { RequestEffects } from './data/request.effects'; +import { MenuEffects } from '../shared/menu/menu.effects'; import { AuthEffects } from './auth/auth.effects'; -import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; +import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { RouteEffects } from './services/route.effects'; +import { RequestEffects } from './data/request.effects'; +import { UUIDIndexEffects } from './index/index.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { RouterEffects } from './router/router.effects'; +import { RouteEffects } from './services/route.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..10433caa26 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,12 +1,30 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; - +import { + ModuleWithProviders, + NgModule, + Optional, + SkipSelf, +} from '@angular/core'; import { EffectsModule } from '@ngrx/effects'; +import { + Action, + StoreConfig, + StoreModule, +} from '@ngrx/store'; -import { Action, StoreConfig, StoreModule } from '@ngrx/store'; +import { environment } from '../../environments/environment'; +import { LdnItemfiltersService } from '../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service'; +import { LdnServicesService } from '../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { Itemfilter } from '../admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters'; +import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; +import { AdminNotifyMessage } from '../admin/admin-notify-dashboard/models/admin-notify-message.model'; +import { storeModuleConfig } from '../app.reducer'; +import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status/notify-requests-status.model'; import { MyDSpaceGuard } from '../my-dspace-page/my-dspace.guard'; - +import { Process } from '../process-page/processes/process.model'; +import { Script } from '../process-page/scripts/script.model'; +import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { isNotEmpty } from '../shared/empty.util'; import { HostWindowService } from '../shared/host-window.service'; import { MenuService } from '../shared/menu/menu.service'; @@ -14,18 +32,27 @@ import { EndpointMockingRestService } from '../shared/mocks/dspace-rest/endpoint import { MOCK_RESPONSE_MAP, mockResponseMap, - ResponseMapMock + ResponseMapMock, } from '../shared/mocks/dspace-rest/mocks/response-map.mock'; import { NotificationsService } from '../shared/notifications/notifications.service'; +import { AccessStatusObject } from '../shared/object-collection/shared/badges/access-status-badge/access-status.model'; +import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SidebarService } from '../shared/sidebar/sidebar.service'; +import { Subscription } from '../shared/subscriptions/models/subscription.model'; +import { CoarNotifyConfigDataService } from '../submission/sections/section-coar-notify/coar-notify-config-data.service'; +import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthStatus } from './auth/models/auth-status.model'; +import { ShortLivedToken } from './auth/models/short-lived-token.model'; +import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { BrowseService } from './browse/browse.service'; import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { ObjectCacheService } from './cache/object-cache.service'; +import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; +import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model'; import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; import { SubmissionSectionModel } from './config/models/config-submission-section.model'; @@ -33,9 +60,14 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service'; import { coreEffects } from './core.effects'; import { coreReducers } from './core.reducers'; +import { CoreState } from './core-state.model'; +import { AccessStatusDataService } from './data/access-status-data.service'; +import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; +import { BitstreamDataService } from './data/bitstream-data.service'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; +import { ConfigurationDataService } from './data/configuration-data.service'; import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; import { DebugResponseParsingService } from './data/debug-response-parsing.service'; import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; @@ -47,141 +79,132 @@ import { EntityTypeDataService } from './data/entity-type-data.service'; import { ExternalSourceDataService } from './data/external-source-data.service'; import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; +import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; +import { SiteAdministratorGuard } from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; +import { FeatureDataService } from './data/feature-authorization/feature-data.service'; import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { ItemDataService } from './data/item-data.service'; +import { ItemTemplateDataService } from './data/item-template-data.service'; import { LookupRelationService } from './data/lookup-relation.service'; +import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; +import { NotifyRequestsStatusDataService } from './data/notify-services-status-data.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { RelationshipTypeDataService } from './data/relationship-type-data.service'; +import { ProcessDataService } from './data/processes/process-data.service'; +import { ScriptDataService } from './data/processes/script-data.service'; import { RelationshipDataService } from './data/relationship-data.service'; -import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service'; +import { RelationshipTypeDataService } from './data/relationship-type-data.service'; +import { Root } from './data/root.model'; +import { RootDataService } from './data/root-data.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { SiteDataService } from './data/site-data.service'; +import { VersionDataService } from './data/version-data.service'; +import { VersionHistoryDataService } from './data/version-history-data.service'; +import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { DspaceRestService } from './dspace-rest/dspace-rest.service'; +import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; +import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; +import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; import { EPersonDataService } from './eperson/eperson-data.service'; +import { GroupDataService } from './eperson/group-data.service'; import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; +import { FeedbackDataService } from './feedback/feedback-data.service'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; +import { MetadataService } from './metadata/metadata.service'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; -import { MetadataService } from './metadata/metadata.service'; +import { SuggestionSource } from './notifications/models/suggestion-source.model'; +import { SuggestionTarget } from './notifications/models/suggestion-target.model'; +import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceSourceObject } from './notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceTopicObject } from './notifications/qa/models/quality-assurance-topic.model'; +import { OrcidHistory } from './orcid/model/orcid-history.model'; +import { OrcidQueue } from './orcid/model/orcid-queue.model'; +import { OrcidAuthService } from './orcid/orcid-auth.service'; +import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; +import { OrcidQueueDataService } from './orcid/orcid-queue-data.service'; +import { ResearcherProfile } from './profile/model/researcher-profile.model'; +import { ResearcherProfileDataService } from './profile/researcher-profile-data.service'; import { RegistryService } from './registry/registry.service'; +import { ReloadGuard } from './reload/reload.guard'; +import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; +import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service'; import { RoleService } from './roles/role.service'; -import { FeedbackDataService } from './feedback/feedback-data.service'; - +import { LinkHeadService } from './services/link-head.service'; import { ServerResponseService } from './services/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from './services/window.service'; -import { BitstreamFormat } from './shared/bitstream-format.model'; +import { + NativeWindowFactory, + NativeWindowService, +} from './services/window.service'; +import { Authorization } from './shared/authorization.model'; import { Bitstream } from './shared/bitstream.model'; +import { BitstreamFormat } from './shared/bitstream-format.model'; import { BrowseDefinition } from './shared/browse-definition.model'; import { BrowseEntry } from './shared/browse-entry.model'; import { Bundle } from './shared/bundle.model'; import { Collection } from './shared/collection.model'; import { Community } from './shared/community.model'; +import { ConfigurationProperty } from './shared/configuration-property.model'; import { DSpaceObject } from './shared/dspace-object.model'; -import { ExternalSourceEntry } from './shared/external-source-entry.model'; import { ExternalSource } from './shared/external-source.model'; +import { ExternalSourceEntry } from './shared/external-source-entry.model'; +import { Feature } from './shared/feature.model'; +import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; import { HALEndpointService } from './shared/hal-endpoint.service'; -import { ItemType } from './shared/item-relationships/item-type.model'; -import { RelationshipType } from './shared/item-relationships/relationship-type.model'; -import { Relationship } from './shared/item-relationships/relationship.model'; +import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; import { Item } from './shared/item.model'; +import { ItemType } from './shared/item-relationships/item-type.model'; +import { Relationship } from './shared/item-relationships/relationship.model'; +import { RelationshipType } from './shared/item-relationships/relationship-type.model'; +import { ItemRequest } from './shared/item-request.model'; import { License } from './shared/license.model'; -import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; +import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; +import { Registration } from './shared/registration.model'; +import { SearchService } from './shared/search/search.service'; import { SearchConfigurationService } from './shared/search/search-configuration.service'; import { SearchFilterService } from './shared/search/search-filter.service'; -import { SearchService } from './shared/search/search.service'; +import { SearchConfig } from './shared/search/search-filters/search-config.model'; +import { SequenceService } from './shared/sequence.service'; import { Site } from './shared/site.model'; +import { TemplateItem } from './shared/template-item.model'; import { UUIDService } from './shared/uuid.service'; +import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; +import { Version } from './shared/version.model'; +import { VersionHistory } from './shared/version-history.model'; +import { UsageReport } from './statistics/models/usage-report.model'; +import { CorrectionTypeDataService } from './submission/correctiontype-data.service'; +import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; +import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { WorkflowItem } from './submission/models/workflowitem.model'; import { WorkspaceItem } from './submission/models/workspaceitem.model'; +import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; +import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; +import { SubmissionDuplicateDataService } from './submission/submission-duplicate-data.service'; import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; import { SubmissionRestService } from './submission/submission-rest.service'; +import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; +import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; +import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; +import { VocabularyService } from './submission/vocabularies/vocabulary.service'; +import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; import { WorkflowItemDataService } from './submission/workflowitem-data.service'; import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; +import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; +import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model'; import { ClaimedTask } from './tasks/models/claimed-task-object.model'; import { PoolTask } from './tasks/models/pool-task-object.model'; +import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model'; +import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model'; import { TaskObject } from './tasks/models/task-object.model'; +import { WorkflowAction } from './tasks/models/workflow-action-object.model'; import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; -import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; -import { BitstreamDataService } from './data/bitstream-data.service'; -import { environment } from '../../environments/environment'; -import { storeModuleConfig } from '../app.reducer'; -import { VersionDataService } from './data/version-data.service'; -import { VersionHistoryDataService } from './data/version-history-data.service'; -import { Version } from './shared/version.model'; -import { VersionHistory } from './shared/version-history.model'; -import { Script } from '../process-page/scripts/script.model'; -import { Process } from '../process-page/processes/process.model'; -import { ProcessDataService } from './data/processes/process-data.service'; -import { ScriptDataService } from './data/processes/script-data.service'; -import { WorkflowActionDataService } from './data/workflow-action-data.service'; -import { WorkflowAction } from './tasks/models/workflow-action-object.model'; -import { ItemTemplateDataService } from './data/item-template-data.service'; -import { TemplateItem } from './shared/template-item.model'; -import { Feature } from './shared/feature.model'; -import { Authorization } from './shared/authorization.model'; -import { FeatureDataService } from './data/feature-authorization/feature-data.service'; -import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { - SiteAdministratorGuard -} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; -import { Registration } from './shared/registration.model'; -import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; -import { MetadataFieldDataService } from './data/metadata-field-data.service'; -import { TokenResponseParsingService } from './auth/token-response-parsing.service'; -import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; -import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; -import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; -import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; -import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; -import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; -import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; -import { VocabularyService } from './submission/vocabularies/vocabulary.service'; -import { ConfigurationDataService } from './data/configuration-data.service'; -import { ConfigurationProperty } from './shared/configuration-property.model'; -import { ReloadGuard } from './reload/reload.guard'; -import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; -import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; -import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; -import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; -import { ShortLivedToken } from './auth/models/short-lived-token.model'; -import { UsageReport } from './statistics/models/usage-report.model'; -import { RootDataService } from './data/root-data.service'; -import { Root } from './data/root.model'; -import { SearchConfig } from './shared/search/search-filters/search-config.model'; -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 { 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'; -import { AccessStatusObject } from '../shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { AccessStatusDataService } from './data/access-status-data.service'; -import { LinkHeadService } from './services/link-head.service'; -import { ResearcherProfileDataService } from './profile/researcher-profile-data.service'; -import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; -import { ResearcherProfile } from './profile/model/researcher-profile.model'; -import { OrcidQueueDataService } from './orcid/orcid-queue-data.service'; -import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; -import { OrcidQueue } from './orcid/model/orcid-queue.model'; -import { OrcidHistory } from './orcid/model/orcid-history.model'; -import { OrcidAuthService } from './orcid/orcid-auth.service'; -import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; -import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; -import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; -import { Subscription } from '../shared/subscriptions/models/subscription.model'; -import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; -import { ItemRequest } from './shared/item-request.model'; -import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; -import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; -import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; -import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; -import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -198,7 +221,7 @@ export const restServiceFactory = (mocks: ResponseMapMock, http: HttpClient) => const IMPORTS = [ CommonModule, StoreModule.forFeature('core', coreReducers, storeModuleConfig as StoreConfig), - EffectsModule.forFeature(coreEffects) + EffectsModule.forFeature(coreEffects), ]; const DECLARATIONS = []; @@ -218,6 +241,7 @@ const PROVIDERS = [ HALEndpointService, HostWindowService, ItemDataService, + SubmissionDuplicateDataService, MetadataService, ObjectCacheService, PaginationComponentOptions, @@ -304,7 +328,12 @@ const PROVIDERS = [ OrcidAuthService, OrcidQueueDataService, OrcidHistoryDataService, - SupervisionOrderDataService + SupervisionOrderDataService, + CorrectionTypeDataService, + LdnServicesService, + LdnItemfiltersService, + CoarNotifyConfigDataService, + NotifyRequestsStatusDataService, ]; /** @@ -369,9 +398,12 @@ export const models = ShortLivedToken, Registration, UsageReport, + QualityAssuranceTopicObject, + QualityAssuranceEventObject, Root, SearchConfig, SubmissionAccessesModel, + QualityAssuranceSourceObject, AccessStatusObject, ResearcherProfile, OrcidQueue, @@ -380,22 +412,29 @@ export const models = IdentifierData, Subscription, ItemRequest, - BulkAccessConditionOptions + BulkAccessConditionOptions, + SuggestionTarget, + SuggestionSource, + LdnService, + Itemfilter, + SubmissionCoarNotifyConfig, + NotifyRequestsStatus, + AdminNotifyMessage, ]; @NgModule({ imports: [ - ...IMPORTS + ...IMPORTS, ], declarations: [ - ...DECLARATIONS + ...DECLARATIONS, ], exports: [ - ...EXPORTS + ...EXPORTS, ], providers: [ - ...PROVIDERS - ] + ...PROVIDERS, + ], }) export class CoreModule { @@ -403,8 +442,8 @@ export class CoreModule { return { ngModule: CoreModule, providers: [ - ...PROVIDERS - ] + ...PROVIDERS, + ], }; } diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c0165c5384..fda1e05df0 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,19 +1,17 @@ -import { ActionReducerMap, } from '@ngrx/store'; +import { ActionReducerMap } from '@ngrx/store'; -import { objectCacheReducer } from './cache/object-cache.reducer'; -import { indexReducer } from './index/index.reducer'; -import { requestReducer } from './data/request.reducer'; +import { bitstreamFormatReducer } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { authReducer } from './auth/auth.reducer'; -import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { objectCacheReducer } from './cache/object-cache.reducer'; import { serverSyncBufferReducer } from './cache/server-sync-buffer.reducer'; -import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; -import { routeReducer } from './services/route.reducer'; -import { - bitstreamFormatReducer -} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; -import { historyReducer } from './history/history.reducer'; -import { metaTagReducer } from './metadata/meta-tag.reducer'; import { CoreState } from './core-state.model'; +import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; +import { requestReducer } from './data/request.reducer'; +import { historyReducer } from './history/history.reducer'; +import { indexReducer } from './index/index.reducer'; +import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { metaTagReducer } from './metadata/meta-tag.reducer'; +import { routeReducer } from './services/route.reducer'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, @@ -26,5 +24,5 @@ export const coreReducers: ActionReducerMap = { 'auth': authReducer, 'json/patch': jsonPatchOperationsReducer, 'metaTag': metaTagReducer, - 'route': routeReducer + 'route': routeReducer, }; diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts index 77c7974de2..899afb9be9 100644 --- a/src/app/core/core.selectors.ts +++ b/src/app/core/core.selectors.ts @@ -1,4 +1,5 @@ import { createFeatureSelector } from '@ngrx/store'; + import { CoreState } from './core-state.model'; /** diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index 18b8cb5d65..1240585027 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -1,17 +1,21 @@ -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { Observable } from 'rxjs'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { GetRequest } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; -import { AccessStatusDataService } from './access-status-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Item } from '../shared/item.model'; +import { AccessStatusDataService } from './access-status-data.service'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; @@ -29,12 +33,12 @@ describe('AccessStatusDataService', () => { name: 'test-item', _links: { accessStatus: { - href: `https://rest.api/items/${itemId}/accessStatus` + href: `https://rest.api/items/${itemId}/accessStatus`, }, self: { - href: `https://rest.api/items/${itemId}` - } - } + href: `https://rest.api/items/${itemId}`, + }, + }, }); describe('when the requests are successful', () => { @@ -69,10 +73,10 @@ describe('AccessStatusDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ + buildSingle: buildResponse$, }); objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index e8b77245e8..56db0e55e7 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -1,15 +1,16 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model'; -import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; import { Item } from '../shared/item.model'; import { BaseDataService } from './base/base-data.service'; import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service responsible for retrieving the access status of Items diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts index 025791d6dc..cf11decde4 100644 --- a/src/app/core/data/array-move-change-analyzer.service.spec.ts +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -1,7 +1,8 @@ -import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; import { moveItemInArray } from '@angular/cdk/drag-drop'; import { Operation } from 'fast-json-patch'; +import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; + /** * Helper class for creating move tests * Define a "from" and "to" index to move objects within the array before comparing @@ -28,7 +29,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', 'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1', '0f608168-cdfc-46b0-92ce-889f7d3ac684', - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); @@ -72,7 +73,7 @@ describe('ArrayMoveChangeAnalyzer', () => { '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', undefined, undefined, - '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + '546f9f5c-15dc-4eec-86fe-648007ac9e1c', ]; }); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts index 36744e9f96..95d86f1032 100644 --- a/src/app/core/data/array-move-change-analyzer.service.ts +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -1,6 +1,7 @@ -import { MoveOperation } from 'fast-json-patch'; -import { Injectable } from '@angular/core'; import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { Injectable } from '@angular/core'; +import { MoveOperation } from 'fast-json-patch'; + import { hasValue } from '../../shared/empty.util'; /** diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts index da9fa7a643..f6707d3582 100644 --- a/src/app/core/data/base-response-parsing.service.spec.ts +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ /* eslint-disable max-classes-per-file */ -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GetRequest} from './request.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { GetRequest } from './request.models'; import { RestRequest } from './rest-request.model'; class TestService extends BaseResponseParsingService { @@ -35,7 +35,7 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = undefined; objectCache = jasmine.createSpyObj('objectCache', { - add: {} + add: {}, }); service = new TestService(objectCache); }); @@ -58,8 +58,8 @@ describe('BaseResponseParsingService', () => { beforeEach(() => { obj = Object.assign(new DSpaceObject(), { _links: { - self: { href: 'obj-selflink' } - } + self: { href: 'obj-selflink' }, + }, }); }); @@ -79,8 +79,8 @@ describe('BaseResponseParsingService', () => { data = { type: 'NotARealType', _links: { - self: { href: 'data-selflink' } - } + self: { href: 'data-selflink' }, + }, }; }); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 18e6623683..63b4961b31 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,14 +1,21 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { environment } from '../../../environments/environment'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PageInfo } from '../shared/page-info.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; import { RestRequest } from './rest-request.model'; @@ -64,7 +71,7 @@ export abstract class BaseResponseParsingService { } else if (isRestDataObject(data._embedded[property])) { object[property] = this.retrieveObjectOrUrl(parsedObj); } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)); } } }); @@ -96,14 +103,14 @@ export abstract class BaseResponseParsingService { list = this.flattenSingleKeyObject(list); } const page: ObjectDomain[] = this.processArray(list, request); - return buildPaginatedList(pageInfo, page,); + return buildPaginatedList(pageInfo, page); } protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -139,7 +146,7 @@ export abstract class BaseResponseParsingService { let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); 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..3f44ad5e5a 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -5,22 +5,37 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { HALLink } from '../../shared/hal-link.model'; +import { FindListOptions } from '../find-list-options.model'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; const endpoint = 'https://rest.api/core'; @@ -46,38 +61,22 @@ describe('BaseDataService', () => { let requestService; let halService; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; let selfLink; let linksToFollow; let testScheduler; - let remoteDataMocks; + let remoteDataMocks: { [responseType: string]: RemoteData }; + let remoteDataPageMocks: { [responseType: string]: RemoteData }; function initTestService(): TestService { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - objectCache = { - - addPatch: () => { - /* empty */ - }, - getObjectBySelfLink: () => { - /* empty */ - }, - getByHref: () => { - /* empty */ - }, - addDependency: () => { - /* empty */ - }, - removeDependents: () => { - /* empty */ - }, - } as any; + objectCache = new ObjectCacheServiceStub(); selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -88,23 +87,53 @@ describe('BaseDataService', () => { const timeStamp = new Date().getTime(); const msToLive = 15 * 60 * 1000; - const payload = { foo: 'bar' }; + const payload = { + foo: 'bar', + followLink1: {}, + followLink2: {}, + _links: { + self: Object.assign(new HALLink(), { + href: 'self-test-link', + }), + followLink1: Object.assign(new HALLink(), { + href: 'follow-link-1', + }), + followLink2: [ + Object.assign(new HALLink(), { + href: 'follow-link-2-1', + }), + Object.assign(new HALLink(), { + href: 'follow-link-2-2', + }), + ], + }, + }; 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), }; + remoteDataPageMocks = { + 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, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; return new TestService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } @@ -303,19 +332,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 +385,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); @@ -375,6 +408,27 @@ describe('BaseDataService', () => { }); + it('should link all the followLinks of a cached object by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + }; + + expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); describe(`findListByHref`, () => { @@ -387,8 +441,8 @@ describe('BaseDataService', () => { it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { testScheduler.run(({ cold }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); @@ -398,8 +452,8 @@ describe('BaseDataService', () => { it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); @@ -414,8 +468,8 @@ describe('BaseDataService', () => { it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); @@ -426,12 +480,12 @@ describe('BaseDataService', () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData @@ -446,7 +500,7 @@ describe('BaseDataService', () => { it(`should return a the output from reRequestStaleRemoteData`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); const expected = 'a'; const values = { @@ -466,19 +520,19 @@ describe('BaseDataService', () => { it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = 'a-b-c-d-e'; const values = { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -487,19 +541,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: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.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: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -518,18 +574,18 @@ describe('BaseDataService', () => { it(`should not emit a cached completed 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.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = '--b-c-d-e'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); @@ -538,25 +594,49 @@ 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: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.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: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); + it('should link all the followLinks of the cached objects by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', { + a: remoteDataPageMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); }); @@ -566,8 +646,8 @@ describe('BaseDataService', () => { beforeEach(() => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ requestUUIDs: ['request1', 'request2', 'request3'], - dependentRequestUUIDs: ['request4', 'request5'] - })); + dependentRequestUUIDs: ['request4', 'request5'], + } as ObjectCacheEntry)); }); @@ -714,7 +794,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); @@ -729,7 +809,7 @@ describe('BaseDataService', () => { (service as any).addDependency( createFailedRemoteDataObject$('something went wrong'), - observableOf('dependsOnHref') + observableOf('dependsOnHref'), ); expect(addDependencySpy).toHaveBeenCalled(); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index edd6d9e2a4..d09ee21ee0 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -6,34 +6,53 @@ * http://www.dspace.org/license/ */ -import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + AsyncSubject, + from as observableFrom, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + mergeMap, + skipWhile, + switchMap, + take, + tap, + toArray, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { RequestParam } from '../../cache/models/request-param.model'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { HALLink } from '../../shared/hal-link.model'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { GetRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { FindListOptions } from '../find-list-options.model'; -import { PaginatedList } from '../paginated-list.model'; -import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; -import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; export const EMBED_SEPARATOR = '%2F'; /** * Common functionality for data services. * Specific functionality that not all services would need - * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * is implemented in "UpdateDataServiceImpl feature" classes (e.g. {@link CreateData} * - * All DataService (or DataService feature) classes must + * All UpdateDataServiceImpl (or UpdateDataServiceImpl feature) classes must * - extend this class (or {@link IdentifiableDataService}) - * - implement any DataService features it requires in order to forward calls to it + * - implement any UpdateDataServiceImpl features it requires in order to forward calls to it * * ``` * export class SomeDataService extends BaseDataService implements CreateData, SearchData { @@ -235,7 +254,7 @@ export class BaseDataService implements HALDataServic if (hasValue(remoteData) && remoteData.isStale) { requestFn(); } - }) + }), ); } else { return source; @@ -268,15 +287,34 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // 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)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData) => { + if (hasValue(remoteDataObject?.payload?._links)) { + for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { + // only add the followLinks if they are embedded + if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + }), + ); } /** @@ -302,15 +340,38 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // 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)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData>) => { + if (hasValue(remoteDataObject?.payload?.page)) { + for (const object of remoteDataObject.payload.page) { + if (hasValue(object?._links)) { + for (const followLinkName of Object.keys(object._links)) { + // only add the followLinks if they are embedded + if (hasValue(object[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + } + } + }), + ); } /** @@ -329,7 +390,7 @@ export class BaseDataService implements HALDataServic href$.pipe( isNotEmptyOperator(), - take(1) + take(1), ).subscribe((href: string) => { const requestId = this.requestService.generateRequestId(); const request = new GetRequest(requestId, href); @@ -373,19 +434,19 @@ export class BaseDataService implements HALDataServic return this.hasCachedResponse(href$).pipe( switchMap((hasCachedResponse) => { if (hasCachedResponse) { - return this.rdbService.buildSingle(href$).pipe( - getFirstCompletedRemoteData(), - map((rd => rd.hasFailed)) - ); + return this.rdbService.buildSingle(href$).pipe( + getFirstCompletedRemoteData(), + map((rd => rd.hasFailed)), + ); } return observableOf(false); - }) + }), ); } /** * Return the links to traverse from the root of the api to the - * endpoint this DataService represents + * endpoint this UpdateDataServiceImpl represents * * e.g. if the api root links to 'foo', and the endpoint at 'foo' * links to 'bar' the linkPath for the BarDataService would be @@ -420,7 +481,7 @@ export class BaseDataService implements HALDataServic } }), ), - dependsOnHref$ + dependsOnHref$, ); } @@ -437,7 +498,7 @@ export class BaseDataService implements HALDataServic switchMap((oce: ObjectCacheEntry) => { return observableFrom([ ...oce.requestUUIDs, - ...oce.dependentRequestUUIDs + ...oce.dependentRequestUUIDs, ]).pipe( mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), toArray(), diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts index 0b2e0f3930..1248c5ffe4 100644 --- a/src/app/core/data/base/create-data.spec.ts +++ b/src/app/core/data/base/create-data.spec.ts @@ -5,23 +5,33 @@ * * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { CreateData, CreateDataImpl } from './create-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -import { RequestParam } from '../../cache/models/request-param.model'; import { RestRequestMethod } from '../rest-request-method'; -import { DSpaceObject } from '../../shared/dspace-object.model'; +import { + CreateData, + CreateDataImpl, +} from './create-data'; /** * Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it @@ -149,7 +159,7 @@ describe('CreateDataImpl', () => { describe('create', () => { it('should POST the object to the root endpoint with the given parameters and return the remote data', (done) => { const params = [ - new RequestParam('abc', 123), new RequestParam('def', 456) + new RequestParam('abc', 123), new RequestParam('def', 456), ]; buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index 3ffcd9adf2..13216f8796 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -5,22 +5,31 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; -import { RequestParam } from '../../cache/models/request-param.model'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; -import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators'; -import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; -import { getClassForType } from '../../cache/builders/build-decorators'; -import { CreateRequest } from '../request.models'; +import { + distinctUntilChanged, + map, + take, + takeWhile, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../../shared/empty.util'; import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getClassForType } from '../../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { RequestParam } from '../../cache/models/request-param.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { CreateRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can create objects. @@ -37,7 +46,7 @@ export interface CreateData { } /** - * A DataService feature to create objects. + * A UpdateDataServiceImpl feature to create objects. * * Concrete data services can use this feature by implementing {@link CreateData} * and delegating its method to an inner instance of this class. @@ -95,7 +104,7 @@ export class CreateDataImpl extends BaseDataService) => rd.isLoading, true) + takeWhile((rd: RemoteData) => rd.isLoading, true), ).subscribe((rd: RemoteData) => { if (rd.hasFailed) { this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); diff --git a/src/app/core/data/base/data-service.decorator.spec.ts b/src/app/core/data/base/data-service.decorator.spec.ts index e09c531a56..296371be69 100644 --- a/src/app/core/data/base/data-service.decorator.spec.ts +++ b/src/app/core/data/base/data-service.decorator.spec.ts @@ -6,11 +6,15 @@ * * http://www.dspace.org/license/ */ +import { v4 as uuidv4 } from 'uuid'; + import { ResourceType } from '../../shared/resource-type'; import { BaseDataService } from './base-data.service'; +import { + dataService, + getDataServiceFor, +} from './data-service.decorator'; import { HALDataService } from './hal-data-service.interface'; -import { dataService, getDataServiceFor } from './data-service.decorator'; -import { v4 as uuidv4 } from 'uuid'; class TestService extends BaseDataService { } diff --git a/src/app/core/data/base/data-service.decorator.ts b/src/app/core/data/base/data-service.decorator.ts index fbde9bd94f..600fb5e3e3 100644 --- a/src/app/core/data/base/data-service.decorator.ts +++ b/src/app/core/data/base/data-service.decorator.ts @@ -6,10 +6,14 @@ * http://www.dspace.org/license/ */ import { InjectionToken } from '@angular/core'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { CacheableObject } from '../../cache/cacheable-object.model'; -import { ResourceType } from '../../shared/resource-type'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { ResourceType } from '../../shared/resource-type'; import { HALDataService } from './hal-data-service.interface'; export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>>('getDataServiceFor', { diff --git a/src/app/core/data/base/delete-data.spec.ts b/src/app/core/data/base/delete-data.spec.ts index a076473b0f..53d651402f 100644 --- a/src/app/core/data/base/delete-data.spec.ts +++ b/src/app/core/data/base/delete-data.spec.ts @@ -5,24 +5,35 @@ * * http://www.dspace.org/license/ */ -import { constructIdEndpointDefault } from './identifiable-data.service'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; import { RequestEntryState } from '../request-entry-state.model'; -import { DeleteData, DeleteDataImpl } from './delete-data'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { RestRequestMethod } from '../rest-request-method'; +import { + DeleteData, + DeleteDataImpl, +} from './delete-data'; +import { constructIdEndpointDefault } from './identifiable-data.service'; /** * Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it @@ -34,7 +45,7 @@ export function testDeleteDataImplementation(serviceFactory: () => DeleteData { @@ -105,13 +116,13 @@ describe('DeleteDataImpl', () => { }, getByHref: () => { /* empty */ - } + }, } as any; notificationsService = {} as NotificationsService; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts index 807d9d838e..e758ee40fa 100644 --- a/src/app/core/data/base/delete-data.ts +++ b/src/app/core/data/base/delete-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { NoContent } from '../../shared/NoContent.model'; import { switchMap } from 'rxjs/operators'; -import { DeleteRequest } from '../request.models'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NoContent } from '../../shared/NoContent.model'; +import { RemoteData } from '../remote-data'; +import { DeleteRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; export interface DeleteData { /** diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts index 6a73e032d0..f2c48feb76 100644 --- a/src/app/core/data/base/find-all-data.spec.ts +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -5,24 +5,33 @@ * * http://www.dspace.org/license/ */ -import { FindAllData, FindAllDataImpl } from './find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; -import { RequestParam } from '../../cache/models/request-param.model'; - -import { RequestService } from '../request.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { + SortDirection, + SortOptions, +} from '../../cache/models/sort-options.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { + FindAllData, + FindAllDataImpl, +} from './find-all-data'; /** * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it @@ -143,8 +152,8 @@ describe('FindAllDataImpl', () => { options = {}; (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - }, + expect(value).toBe(endpoint); + }, ); }); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts index 57884e537e..0d68689e61 100644 --- a/src/app/core/data/base/find-all-data.ts +++ b/src/app/core/data/base/find-all-data.ts @@ -7,18 +7,23 @@ */ import { Observable } from 'rxjs'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, +} from 'rxjs/operators'; + import { isNotEmpty } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that list all of its objects. @@ -42,7 +47,7 @@ export interface FindAllData { } /** - * A DataService feature to list all objects. + * A UpdateDataServiceImpl feature to list all objects. * * Concrete data services can use this feature by implementing {@link FindAllData} * and delegating its method to an inner instance of this class. @@ -87,10 +92,9 @@ export class FindAllDataImpl extends BaseDataService< * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { - let endpoint$: Observable; const args = []; - endpoint$ = this.getBrowseEndpoint(options).pipe( + const endpoint$ = this.getBrowseEndpoint(options).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), distinctUntilChanged(), diff --git a/src/app/core/data/base/hal-data-service.interface.ts b/src/app/core/data/base/hal-data-service.interface.ts index 6959399760..1ffdffaa7c 100644 --- a/src/app/core/data/base/hal-data-service.interface.ts +++ b/src/app/core/data/base/hal-data-service.interface.ts @@ -6,11 +6,12 @@ * http://www.dspace.org/license/ */ import { Observable } from 'rxjs'; + import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; +import { HALResource } from '../../shared/hal-resource.model'; import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; -import { HALResource } from '../../shared/hal-resource.model'; +import { RemoteData } from '../remote-data'; /** * An interface defining the minimum functionality needed for a data service to resolve HAL resources. diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts index 11af83ff9f..528d6c4945 100644 --- a/src/app/core/data/base/identifiable-data.service.spec.ts +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -5,21 +5,25 @@ * * http://www.dspace.org/license/ */ -import { FindListOptions } from '../find-list-options.model'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { IdentifiableDataService } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { EMBED_SEPARATOR } from './base-data.service'; +import { IdentifiableDataService } from './identifiable-data.service'; const endpoint = 'https://rest.api/core'; @@ -63,12 +67,12 @@ describe('IdentifiableDataService', () => { }, getByHref: () => { /* empty */ - } + }, } as any; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), - followLink('b') + followLink('b'), ]; testScheduler = new TestScheduler((actual, expected) => { @@ -132,7 +136,7 @@ describe('IdentifiableDataService', () => { resourceIdMock, followLink('bundles', { shouldEmbed: false }), followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') + followLink('templateItemOf'), ); expect(result).toEqual(expected); }); diff --git a/src/app/core/data/base/identifiable-data.service.ts b/src/app/core/data/base/identifiable-data.service.ts index 904f925765..da3167903e 100644 --- a/src/app/core/data/base/identifiable-data.service.ts +++ b/src/app/core/data/base/identifiable-data.service.ts @@ -5,16 +5,17 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { RemoteData } from '../remote-data'; -import { BaseDataService } from './base-data.service'; -import { RequestService } from '../request.service'; + +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for the method to construct an ID endpoint. diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts index a55b1229b8..03c15199a7 100644 --- a/src/app/core/data/base/patch-data.spec.ts +++ b/src/app/core/data/base/patch-data.spec.ts @@ -6,28 +6,38 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { RequestService } from '../request.service'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; -import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { PatchData, PatchDataImpl } from './patch-data'; -import { ChangeAnalyzer } from '../change-analyzer'; -import { Item } from '../../shared/item.model'; -import { compare, Operation } from 'fast-json-patch'; -import { PatchRequest } from '../request.models'; import { DSpaceObject } from '../../shared/dspace-object.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { constructIdEndpointDefault } from './identifiable-data.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { Item } from '../../shared/item.model'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { PatchRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; import { RestRequestMethod } from '../rest-request-method'; +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './patch-data'; /** * Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it @@ -182,15 +192,15 @@ describe('PatchDataImpl', () => { _links: { self: { href: 'dso-href', - } - } + }, + }, }; const operations = [ Object.assign({ op: 'move', from: '/1', - path: '/5' - }) as Operation + path: '/5', + }) as Operation, ]; it('should send a PatchRequest', () => { @@ -224,12 +234,12 @@ describe('PatchDataImpl', () => { dso = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name1 }] + metadata: [{ key: 'dc.title', value: name1 }], }); dso2 = Object.assign(new DSpaceObject(), { _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name2 }] + metadata: [{ key: 'dc.title', value: name2 }], }); spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index e30c394a34..adcf98ef94 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -5,22 +5,36 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; -import { find, map, mergeMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { PatchRequest } from '../request.models'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; -import { ChangeAnalyzer } from '../change-analyzer'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { RestRequestMethod } from '../rest-request-method'; -import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; +import { + find, + map, + mergeMap, +} from 'rxjs/operators'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../../shared/operators'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { RemoteData } from '../remote-data'; +import { PatchRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { RestRequestMethod } from '../rest-request-method'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './identifiable-data.service'; /** * Interface for a data service that can patch and update objects. @@ -54,7 +68,7 @@ export interface PatchData { } /** - * A DataService feature to patch and update objects. + * A UpdateDataServiceImpl feature to patch and update objects. * * Concrete data services can use this feature by implementing {@link PatchData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts index 6287fe91b1..1430bb3106 100644 --- a/src/app/core/data/base/put-data.spec.ts +++ b/src/app/core/data/base/put-data.spec.ts @@ -6,20 +6,27 @@ * http://www.dspace.org/license/ */ -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; -import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; -import { RemoteData } from '../remote-data'; -import { RequestEntryState } from '../request-entry-state.model'; -import { PutData, PutDataImpl } from './put-data'; -import { RestRequestMethod } from '../rest-request-method'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; +import { RestRequestMethod } from '../rest-request-method'; +import { + PutData, + PutDataImpl, +} from './put-data'; /** * Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it @@ -127,7 +134,7 @@ describe('PutDataImpl', () => { metadata: { // recognized properties will be serialized ['dc.title']: [ { language: 'en', value: 'some object' }, - ] + ], }, data: [ 1, 2, 3, 4 ], // unrecognized properties won't be serialized _links: { self: { href: selfLink } }, @@ -144,7 +151,7 @@ describe('PutDataImpl', () => { method: RestRequestMethod.PUT, body: { // _links are not serialized uuid: obj.uuid, - metadata: obj.metadata + metadata: obj.metadata, }, })); done(); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts index bd2a8d2929..e9d2b01eb8 100644 --- a/src/app/core/data/base/put-data.ts +++ b/src/app/core/data/base/put-data.ts @@ -5,18 +5,19 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { RemoteData } from '../remote-data'; + +import { hasValue } from '../../../shared/empty.util'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { PutRequest } from '../request.models'; -import { hasValue } from '../../../shared/empty.util'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../remote-data'; +import { PutRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Interface for a data service that can send PUT requests. @@ -31,7 +32,7 @@ export interface PutData { } /** - * A DataService feature to send PUT requests. + * A UpdateDataServiceImpl feature to send PUT requests. * * Concrete data services can use this feature by implementing {@link PutData} * and delegating its method to an inner instance of this class. @@ -55,7 +56,7 @@ export class PutDataImpl extends BaseDataService i */ put(object: T): Observable> { const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor).serialize(object); const request = new PutRequest(requestId, object._links.self.href, serializedObject); if (hasValue(this.responseMsToLive)) { diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts index 31dddeddfc..af9f87bf2c 100644 --- a/src/app/core/data/base/search-data.spec.ts +++ b/src/app/core/data/base/search-data.spec.ts @@ -5,12 +5,17 @@ * * http://www.dspace.org/license/ */ -import { constructSearchEndpointDefault, SearchData, SearchDataImpl } from './search-data'; -import { FindListOptions } from '../find-list-options.model'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; import { of as observableOf } from 'rxjs'; -import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; + import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../find-list-options.model'; +import { + constructSearchEndpointDefault, + SearchData, + SearchDataImpl, +} from './search-data'; /** * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts index 536d6d6e25..f758affa28 100644 --- a/src/app/core/data/base/search-data.ts +++ b/src/app/core/data/base/search-data.ts @@ -5,19 +5,26 @@ * * http://www.dspace.org/license/ */ -import { CacheableObject } from '../../cache/cacheable-object.model'; -import { BaseDataService } from './base-data.service'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; -import { hasNoValue, isNotEmpty } from '../../../shared/empty.util'; -import { FindListOptions } from '../find-list-options.model'; +import { + filter, + map, +} from 'rxjs/operators'; + +import { + hasNoValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { BaseDataService } from './base-data.service'; /** * Shorthand type for method to construct a search endpoint @@ -51,7 +58,7 @@ export interface SearchData { } /** - * A DataService feature to search for objects. + * A UpdateDataServiceImpl feature to search for objects. * * Concrete data services can use this feature by implementing {@link SearchData} * and delegating its method to an inner instance of this class. @@ -112,10 +119,9 @@ export class SearchDataImpl extends BaseDataService[]): Observable { - let result$: Observable; const args = []; - result$ = this.getSearchEndpoint(searchMethod); + const result$ = this.getSearchEndpoint(searchMethod); return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index 89178f8dd2..95fe5f593f 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -1,26 +1,41 @@ import { TestBed } from '@angular/core/testing'; -import { BitstreamDataService } from './bitstream-data.service'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { ItemMock } from 'src/app/shared/mocks/item.mock'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, +} from 'src/app/shared/remote-data.utils'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { Observable, of as observableOf } from 'rxjs'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; -import { PatchRequest, PutRequest } from './request.models'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; +import { Bundle } from '../shared/bundle.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import objectContaining = jasmine.objectContaining; import { RemoteData } from './remote-data'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import objectContaining = jasmine.objectContaining; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -29,39 +44,41 @@ describe('BitstreamDataService', () => { let halService: HALEndpointService; let bitstreamFormatService: BitstreamFormatDataService; let rdbService: RemoteDataBuildService; + let bundleDataService: BundleDataService; const bitstreamFormatHref = 'rest-api/bitstreamformats'; const bitstream1 = Object.assign(new Bitstream(), { id: 'fake-bitstream1', uuid: 'fake-bitstream1', _links: { - self: { href: 'fake-bitstream1-self' } - } + self: { href: 'fake-bitstream1-self' }, + }, }); const bitstream2 = Object.assign(new Bitstream(), { id: 'fake-bitstream2', uuid: 'fake-bitstream2', _links: { - self: { href: 'fake-bitstream2-self' } - } + self: { href: 'fake-bitstream2-self' }, + }, }); const format = Object.assign(new BitstreamFormat(), { id: '2', shortDescription: 'PNG', description: 'Portable Network Graphics', - supportLevel: BitstreamFormatSupportLevel.Known + supportLevel: BitstreamFormatSupportLevel.Known, }); const url = 'fake-bitstream-url'; beforeEach(() => { objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { - getBrowseEndpoint: observableOf(bitstreamFormatHref) + getBrowseEndpoint: observableOf(bitstreamFormatHref), }); + rdbService = getMockRemoteDataBuildService(); TestBed.configureTestingModule({ @@ -76,6 +93,7 @@ describe('BitstreamDataService', () => { ], }); service = TestBed.inject(BitstreamDataService); + bundleDataService = TestBed.inject(BundleDataService); }); describe('composition', () => { @@ -118,6 +136,32 @@ describe('BitstreamDataService', () => { expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); }); + describe('findPrimaryBitstreamByItemAndName', () => { + it('should return primary bitstream', () => { + const exprected$ = cold('(a|)', { a: bitstream1 } ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createSuccessfulRemoteDataObject(bitstream1)), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return null if primary bitstream has not be succeeded ', () => { + const exprected$ = cold('(a|)', { a: null } ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createFailedRemoteDataObject()), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return EMPTY if nothing where found', () => { + const exprected$ = cold('(|)', {} ); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createFailedRemoteDataObject())); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + }); + it('should be able to delete multiple bitstreams', () => { service.removeMultiple([bitstream1, bitstream2]); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index bb4ec28166..bc89a54649 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,39 +1,71 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { find, map, switchMap, take } from 'rxjs/operators'; +import { + Operation, + RemoveOperation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + EMPTY, + Observable, +} from 'rxjs'; +import { + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Bitstream } from '../shared/bitstream.model'; import { BITSTREAM } from '../shared/bitstream.resource-type'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { BundleDataService } from './bundle-data.service'; -import { buildPaginatedList, PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { PatchRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { PageInfo } from '../shared/page-info.model'; -import { RequestParam } from '../cache/models/request-param.model'; -import { sendRequest } from '../shared/request.operators'; -import { FindListOptions } from './find-list-options.model'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RestRequestMethod } from './rest-request-method'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NoContent } from '../shared/NoContent.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { PageInfo } from '../shared/page-info.model'; +import { sendRequest } from '../shared/request.operators'; import { dataService } from './base/data-service.decorator'; -import { Operation, RemoveOperation } from 'fast-json-patch'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + PatchRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -107,7 +139,7 @@ export class BitstreamDataService extends IdentifiableDataService imp } else { return [bundleRD as any]; } - }) + }), ); } @@ -120,10 +152,10 @@ export class BitstreamDataService extends IdentifiableDataService imp const requestId = this.requestService.generateRequestId(); const bitstreamHref$ = this.getBrowseEndpoint().pipe( map((href: string) => `${href}/${bitstream.id}`), - switchMap((href: string) => this.halService.getEndpoint('format', href)) + switchMap((href: string) => this.halService.getEndpoint('format', href)), ); const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe( - map((href: string) => `${href}/${format.id}`) + map((href: string) => `${href}/${format.id}`), ); observableCombineLatest([bitstreamHref$, formatHref$]).pipe( map(([bitstreamHref, formatHref]) => { @@ -134,7 +166,7 @@ export class BitstreamDataService extends IdentifiableDataService imp return new PutRequest(requestId, bitstreamHref, formatHref, options); }), sendRequest(this.requestService), - take(1) + take(1), ).subscribe(() => { this.requestService.removeByHrefSubstring(bitstream.self + '/format'); }); @@ -177,7 +209,7 @@ export class BitstreamDataService extends IdentifiableDataService imp const hrefObs = this.getSearchByHref( 'byItemHandle', { searchParams }, - ...linksToFollow + ...linksToFollow, ); return this.findByHref( @@ -201,6 +233,37 @@ export class BitstreamDataService extends IdentifiableDataService imp return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); } + + /** + * + * Make a request to get primary bitstream + * in all current use cases, and having it simplifies this method + * + * @param item the {@link Item} the {@link Bundle} is a part of + * @param bundleName the name of the {@link Bundle} we want to find + * {@link Bitstream}s for + * @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 + * @return {Observable} + * Return an observable that constains primary bitstream information or null + */ + public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, followLink('primaryBitstream')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (!rd.hasSucceeded) { + return EMPTY; + } + return rd.payload.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null), + ); + }), + ); + } + /** * Make a new FindListRequest with given search method * diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index 15efebe8c7..234326d453 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -1,21 +1,36 @@ -import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { RestResponse } from '../cache/response.models'; -import { Observable, of as observableOf } from 'rxjs'; -import { Action, Store } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; import { waitForAsync } from '@angular/core/testing'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + Action, + Store, +} from '@ngrx/store'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { RequestEntry } from './request-entry.model'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -31,19 +46,19 @@ describe('BitstreamFormatDataService', () => { const store = { dispatch(action: Action) { // Do Nothing - } + }, } as Store; const requestUUIDs = ['some', 'uuid']; const objectCache = jasmine.createSpyObj('objectCache', { - getByHref: observableOf({ requestUUIDs }) + getByHref: observableOf({ requestUUIDs }), }) as ObjectCacheService; const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: bitstreamFormatsEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; @@ -83,7 +98,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -104,7 +119,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -127,7 +142,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -149,7 +164,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -174,7 +189,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); })); @@ -198,12 +213,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); service.clearBitStreamFormatRequests().subscribe(); @@ -222,7 +237,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -245,7 +260,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -268,7 +283,7 @@ describe('BitstreamFormatDataService', () => { getByUUID: cold('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); service = initTestService(halEndpointService); spyOn(store, 'dispatch'); @@ -289,12 +304,12 @@ describe('BitstreamFormatDataService', () => { getByUUID: hot('a', { a: responseCacheEntry }), setStaleByUUID: observableOf(true), generateRequestId: 'request-id', - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); const halService = { getEndpoint(linkPath: string): Observable { return observableOf(bitstreamFormatsEndpoint); - } + }, } as HALEndpointService; service = initTestService(halService); })); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 0104389815..1006e4eae0 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,30 +1,52 @@ import { Injectable } from '@angular/core'; -import { createSelector, select, Store } from '@ngrx/store'; +import { + createSelector, + select, + Store, +} from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { + distinctUntilChanged, + map, + tap, +} from 'rxjs/operators'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; + +import { + BitstreamFormatsRegistryDeselectAction, + BitstreamFormatsRegistryDeselectAllAction, + BitstreamFormatsRegistrySelectAction, +} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core-state.model'; +import { Bitstream } from '../shared/bitstream.model'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; -import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; +import { NoContent } from '../shared/NoContent.model'; import { sendRequest } from '../shared/request.operators'; -import { CoreState } from '../core-state.model'; +import { dataService } from './base/data-service.decorator'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; -import { NoContent } from '../shared/NoContent.model'; -import { dataService } from './base/data-service.decorator'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; const bitstreamFormatsStateSelector = createSelector( coreSelector, @@ -106,7 +128,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return new PostRequest(requestId, endpointURL, bitstreamFormat); }), - sendRequest(this.requestService) + sendRequest(this.requestService), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -117,7 +139,7 @@ export class BitstreamFormatDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 9fa7239ef7..53d2ec20fc 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,9 +1,9 @@ import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; -import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; class TestService extends BrowseResponseParsingService { constructor(protected objectCache: ObjectCacheService) { @@ -26,22 +26,22 @@ describe('BrowseResponseParsingService', () => { describe('', () => { const mockFlatBrowse = { - id: 'title', - browseType: 'flatBrowse', - type: 'browse', - }; + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; const mockValueList = { - id: 'author', - browseType: 'valueList', - type: 'browse', - }; + id: 'author', + browseType: 'valueList', + type: 'browse', + }; const mockHierarchicalBrowse = { - id: 'srsc', - browseType: 'hierarchicalBrowse', - type: 'browse', - }; + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; it('should deserialize flatBrowses correctly', () => { let deserialized = service.deserialize(mockFlatBrowse); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index a568cdb617..e01fa17f1f 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -1,18 +1,17 @@ import { Injectable } from '@angular/core'; -import { ObjectCacheService } from '../cache/object-cache.service'; + import { hasValue } from '../../shared/empty.util'; -import { - HIERARCHICAL_BROWSE_DEFINITION -} from '../shared/hierarchical-browse-definition.resource-type'; -import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; -import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; -import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; -import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Serializer } from '../serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; /** * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index e3ba438f9b..b2c8be06af 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,19 +1,23 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Item } from '../shared/item.model'; -import { ChangeAnalyzer } from './change-analyzer'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { BundleDataService } from './bundle-data.service'; -import { HALLink } from '../shared/hal-link.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { Bundle } from '../shared/bundle.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core-state.model'; +import { Bundle } from '../shared/bundle.model'; +import { HALLink } from '../shared/hal-link.model'; +import { Item } from '../shared/item.model'; import { testPatchDataImplementation } from './base/patch-data.spec'; +import { BundleDataService } from './bundle-data.service'; +import { ChangeAnalyzer } from './change-analyzer'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { @@ -41,7 +45,7 @@ describe('BundleDataService', () => { bundleHALLink.href = bundleLink; item = new Item(); item._links = { - bundles: bundleHALLink + bundles: bundleHALLink, }; requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; @@ -56,7 +60,7 @@ describe('BundleDataService', () => { }, getObjectBySelfLink: () => { /* empty */ - } + }, } as any; store = {} as Store; return new BundleDataService( @@ -99,30 +103,30 @@ describe('BundleDataService', () => { metadata: { 'dc.title': [ { - value: 'ORIGINAL' - } - ] - } + value: 'ORIGINAL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'THUMBNAIL_BUNDLE', metadata: { 'dc.title': [ { - value: 'THUMBNAIL' - } - ] - } + value: 'THUMBNAIL', + }, + ], + }, }), Object.assign(new Bundle(), { id: 'EXTRA_BUNDLE', metadata: { 'dc.title': [ { - value: 'EXTRA' - } - ] - } + value: 'EXTRA', + }, + ], + }, }), ]; spyOn(service, 'findAllByItem').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(bundles))); diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 19f0e73706..84684c6603 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,28 +1,36 @@ import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { + map, + switchMap, + take, +} from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; import { Bundle } from '../shared/bundle.model'; import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; +import { dataService } from './base/data-service.decorator'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request-entry-state.model'; -import { FindListOptions } from './find-list-options.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RestRequestMethod } from './rest-request-method'; -import { Operation } from 'fast-json-patch'; -import { dataService } from './base/data-service.decorator'; /** * A service to retrieve {@link Bundle}s from the REST API @@ -91,7 +99,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Success, null, matchingBundle, - 200 + 200, ); } else { return new RemoteData( @@ -101,7 +109,7 @@ export class BundleDataService extends IdentifiableDataService implement RequestEntryState.Error, `The bundle with name ${bundleName} was not found.`, null, - 404 + 404, ); } } else { @@ -119,7 +127,7 @@ export class BundleDataService extends IdentifiableDataService implement getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable { return this.getBrowseEndpoint().pipe( switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)), - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2c..431fe941bb 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -1,29 +1,45 @@ -import { CollectionDataService } from './collection-data.service'; -import { RequestService } from './request.service'; +import { + fakeAsync, + tick, +} from '@angular/core/testing'; import { TranslateService } from '@ngx-translate/core'; +import { + cold, + getTestScheduler, + hot, +} from 'jasmine-marbles'; +import { Observable } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { hasNoValue } from '../../shared/empty.util'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, UpdateContentSourceRequest } from './request.models'; -import { ContentSource } from '../shared/content-source.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { Collection } from '../shared/collection.model'; +import { ContentSource } from '../shared/content-source.model'; import { PageInfo } from '../shared/page-info.model'; -import { buildPaginatedList } from './paginated-list.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { hasNoValue } from '../../shared/empty.util'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { buildPaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + ContentSourceRequest, + UpdateContentSourceRequest, +} from './request.models'; +import { RequestService } from './request.service'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +51,7 @@ describe('CollectionDataService', () => { let translate: TranslateService; let notificationsService: any; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: any; const mockCollection1: Collection = Object.assign(new Collection(), { @@ -43,9 +59,9 @@ describe('CollectionDataService', () => { name: 'test-collection-1', _links: { self: { - href: 'https://rest.api/collections/test-collection-1-1' - } - } + href: 'https://rest.api/collections/test-collection-1-1', + }, + }, }); const mockCollection2: Collection = Object.assign(new Collection(), { @@ -53,9 +69,9 @@ describe('CollectionDataService', () => { name: 'test-collection-2', _links: { self: { - href: 'https://rest.api/collections/test-collection-2-2' - } - } + href: 'https://rest.api/collections/test-collection-2-2', + }, + }, }); const mockCollection3: Collection = Object.assign(new Collection(), { @@ -63,9 +79,9 @@ describe('CollectionDataService', () => { name: 'test-collection-3', _links: { self: { - href: 'https://rest.api/collections/test-collection-3-3' - } - } + href: 'https://rest.api/collections/test-collection-3-3', + }, + }, }); const queryString = 'test-string'; @@ -138,7 +154,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollection', () => { const result = service.getAuthorizedCollection(queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -153,7 +169,7 @@ describe('CollectionDataService', () => { it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { const result = service.getAuthorizedCollectionByCommunity(communityId, queryString); const expected = cold('a|', { - a: paginatedListRD + a: paginatedListRD, }); expect(result).toBeObservable(expected); }); @@ -200,19 +216,17 @@ describe('CollectionDataService', () => { } rdbService = jasmine.createSpyObj('rdbService', { buildList: hot('a|', { - a: paginatedListRD + a: paginatedListRD, }), buildFromRequestUUID: buildResponse$, - buildSingle: buildResponse$ - }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + buildSingle: buildResponse$, }); + objectCache = new ObjectCacheServiceStub(); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 405b35c1f9..a85d7c0798 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -2,38 +2,48 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { INotification } from '../../shared/notifications/models/notification.model'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Collection } from '../shared/collection.model'; import { COLLECTION } from '../shared/collection.resource-type'; +import { Community } from '../shared/community.model'; import { ContentSource } from '../shared/content-source.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { dataService } from './base/data-service.decorator'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { ContentSourceRequest, - UpdateContentSourceRequest + UpdateContentSourceRequest, } from './request.models'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; import { RestRequest } from './rest-request.model'; -import { FindListOptions } from './find-list-options.model'; -import { Community } from '../shared/community.model'; -import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(COLLECTION) @@ -73,7 +83,7 @@ export class CollectionDataService extends ComColDataService { getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { const searchHref = 'findSubmitAuthorized'; options = Object.assign({}, options, { - searchParams: [new RequestParam('query', query)] + searchParams: [new RequestParam('query', query)], }); return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( @@ -102,8 +112,8 @@ export class CollectionDataService extends ComColDataService { options = Object.assign({}, options, { searchParams: [ new RequestParam('query', query), - new RequestParam('entityType', entityType) - ] + new RequestParam('entityType', entityType), + ], }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( @@ -121,13 +131,13 @@ export class CollectionDataService extends ComColDataService { * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true): Observable>> { const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ new RequestParam('uuid', communityId), - new RequestParam('query', query) - ] + new RequestParam('query', query), + ], }); return this.searchBy(searchHref, options, reRequestOnStale).pipe( @@ -154,11 +164,11 @@ export class CollectionDataService extends ComColDataService { const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType'; const searchParams = [ new RequestParam('uuid', communityId), - new RequestParam('entityType', entityType) + new RequestParam('entityType', entityType), ]; options = Object.assign({}, options, { - searchParams: searchParams + searchParams: searchParams, }); return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( @@ -179,7 +189,7 @@ export class CollectionDataService extends ComColDataService { return this.searchBy(searchHref, options).pipe( filter((collections: RemoteData>) => !collections.isResponsePending), take(1), - map((collections: RemoteData>) => collections.payload.totalElements > 0) + map((collections: RemoteData>) => collections.payload.totalElements > 0), ); } @@ -189,7 +199,7 @@ export class CollectionDataService extends ComColDataService { */ getHarvesterEndpoint(collectionId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)), ); } @@ -200,7 +210,7 @@ export class CollectionDataService extends ComColDataService { getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable> { const href$ = this.getHarvesterEndpoint(collectionId).pipe( isNotEmptyOperator(), - take(1) + take(1), ); href$.subscribe((href: string) => { @@ -227,7 +237,7 @@ export class CollectionDataService extends ComColDataService { headers = headers.append('Content-Type', 'application/json'); options.headers = headers; return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); - }) + }), ); // Execute the post/put request @@ -255,7 +265,7 @@ export class CollectionDataService extends ComColDataService { return (response as RemoteData).payload; } return response as INotification; - }) + }), ); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 0f9f0fa740..e1fe48c076 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,28 +1,37 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { cold } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; + import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core-state.model'; +import { Bitstream } from '../shared/bitstream.model'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; -import { createFailedRemoteDataObject, createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { BitstreamDataService } from './bitstream-data.service'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; -import { Bitstream } from '../shared/bitstream.model'; -import { testCreateDataImplementation } from './base/create-data.spec'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; -import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { RequestService } from './request.service'; const LINK_NAME = 'test'; @@ -45,7 +54,7 @@ class TestService extends ComColDataService { protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, protected comparator: DSOChangeAnalyzer, - protected linkPath: string + protected linkPath: string, ) { super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } @@ -79,30 +88,30 @@ describe('ComColDataService', () => { const comparator = {} as any; const options = Object.assign(new FindListOptions(), { - scopeID: scopeID + scopeID: scopeID, }); const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const mockHalService = { - getEndpoint: (linkPath) => observableOf(communitiesEndpoint) + getEndpoint: (linkPath) => observableOf(communitiesEndpoint), }; function initRdbService(): RemoteDataBuildService { return jasmine.createSpyObj('rdbService', { - buildSingle : createFailedRemoteDataObject$('Error', 500) + buildSingle : createFailedRemoteDataObject$('Error', 500), }); } function initBitstreamDataService(): BitstreamDataService { return jasmine.createSpyObj('bitstreamDataService', { - deleteByHref: createSuccessfulRemoteDataObject$({}) + deleteByHref: createSuccessfulRemoteDataObject$({}), }); } function initMockCommunityDataService(): CommunityDataService { return jasmine.createSpyObj('cds', { getEndpoint: cold('--a-', { a: communitiesEndpoint }), - getIDHref: communityEndpoint + getIDHref: communityEndpoint, }); } @@ -112,11 +121,11 @@ describe('ComColDataService', () => { d: { _links: { [LINK_NAME]: { - href: scopedEndpoint - } - } - } - }) + href: scopedEndpoint, + }, + }, + }, + }), }); } @@ -132,7 +141,7 @@ describe('ComColDataService', () => { http, bitstreamDataService, comparator, - LINK_NAME + LINK_NAME, ); } @@ -200,12 +209,12 @@ describe('ComColDataService', () => { communityWithParentHref = { _links: { parentCommunity: { - href: 'topLevel/parentCommunity' - } - } + href: 'topLevel/parentCommunity', + }, + }, } as Community; communityWithoutParentHref = { - _links: {} + _links: {}, } as Community; }); @@ -238,9 +247,9 @@ describe('ComColDataService', () => { id: 'a20da287-e174-466a-9926-f66as300d399', metadata: [{ key: 'dc.title', - value: 'parent community' + value: 'parent community', }], - _links: {} + _links: {}, }); }); it('should refresh a specific cached community when the parent link can be resolved', () => { @@ -262,9 +271,9 @@ describe('ComColDataService', () => { dso = { _links: { logo: { - href: 'logo-href' - } - } + href: 'logo-href', + }, + }, }; }); @@ -291,8 +300,8 @@ describe('ComColDataService', () => { _links: { self: { href: 'logo-href', - } - } + }, + }, }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index abc9046cd0..de0d1a3157 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,34 +1,63 @@ -import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { Operation } from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isEmpty, + isNotEmpty, +} from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { Community } from '../shared/community.model'; -import { HALLink } from '../shared/hal-link.model'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { Bitstream } from '../shared/bitstream.model'; import { Collection } from '../shared/collection.model'; -import { BitstreamDataService } from './bitstream-data.service'; +import { Community } from '../shared/community.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { HALLink } from '../shared/hal-link.model'; import { NoContent } from '../shared/NoContent.model'; -import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { RestRequestMethod } from './rest-request-method'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { RequestParam } from '../cache/models/request-param.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { BitstreamDataService } from './bitstream-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { Operation } from 'fast-json-patch'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { private createData: CreateData; @@ -86,7 +115,7 @@ export abstract class ComColDataService extend }), filter((halLink: HALLink) => isNotEmpty(halLink)), map((halLink: HALLink) => halLink.href), - distinctUntilChanged() + distinctUntilChanged(), ); } } @@ -97,7 +126,7 @@ export abstract class ComColDataService extend public findByParent(parentUUID: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getFindByParentHref(parentUUID).pipe( - map((href: string) => this.buildHrefFromFindOptions(href, options)) + map((href: string) => this.buildHrefFromFindOptions(href, options)), ); return this.findListByHref(href$, options, true, true, ...linksToFollow); } @@ -110,7 +139,7 @@ export abstract class ComColDataService extend return this.halService.getEndpoint(this.linkPath).pipe( // We can't use HalLinkService to discover the logo link itself, as objects without a logo // don't have the link, and this method is also used in the createLogo method. - map((href: string) => new URLCombiner(href, id, 'logo').toString()) + map((href: string) => new URLCombiner(href, id, 'logo').toString()), ); } @@ -132,7 +161,7 @@ export abstract class ComColDataService extend } else { return this.bitstreamDataService.deleteByHref(logoRd.payload._links.self.href); } - }) + }), ); } else { return createFailedRemoteDataObject$(`The given object doesn't have a logo`, 400); @@ -148,7 +177,7 @@ export abstract class ComColDataService extend this.findByHref(parentCommunityUrl).pipe( getFirstCompletedRemoteData(), ), - this.halService.getEndpoint('communities/search/top').pipe(take(1)) + this.halService.getEndpoint('communities/search/top').pipe(take(1)), ]).subscribe(([rd, topHref]: [RemoteData, string]) => { if (rd.hasSucceeded && isNotEmpty(rd.payload) && isNotEmpty(rd.payload.id)) { this.requestService.setStaleByHrefSubstring(rd.payload.id); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index efb6d50e84..ad06e9ee91 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,23 +1,28 @@ import { Injectable } from '@angular/core'; - import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { dataService } from './base/data-service.decorator'; +import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { isNotEmpty } from '../../shared/empty.util'; -import { FindListOptions } from './find-list-options.model'; -import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(COMMUNITY) @@ -44,14 +49,14 @@ export class CommunityDataService extends ComColDataService { findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.getEndpoint().pipe( map(href => `${href}/search/top`), - switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)) + switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)), ); } protected getFindByParentHref(parentUUID: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( switchMap((communityEndpointHref: string) => - this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)) + this.halService.getEndpoint('subcommunities', `${communityEndpointHref}/${parentUUID}`)), ); } @@ -59,7 +64,7 @@ export class CommunityDataService extends ComColDataService { return this.getEndpoint().pipe( map((endpoint: string) => this.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), - take(1) + take(1), ); } } diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts index 7fe69c16e5..bccfe45da4 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ConfigurationDataService } from './configuration-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigurationDataService } from './configuration-data.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; describe('ConfigurationDataService', () => { let scheduler: TestScheduler; @@ -18,7 +22,7 @@ describe('ConfigurationDataService', () => { const testObject = { uuid: 'test-property', name: 'test-property', - values: ['value-1', 'value-2'] + values: ['value-1', 'value-2'], } as ConfigurationProperty; const configLink = 'https://rest.api/rest/api/config/properties'; const requestURL = `https://rest.api/rest/api/config/properties/${testObject.name}`; @@ -28,18 +32,18 @@ describe('ConfigurationDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: configLink }) + getEndpoint: cold('a', { a: configLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -70,8 +74,8 @@ describe('ConfigurationDataService', () => { const result = service.findByPropertyName(testObject.name); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index de044e25e3..8293173c14 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,15 +1,15 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { dataService } from './base/data-service.decorator'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(CONFIG_PROPERTY) diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 066ccf28c9..c4eaa8418d 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; + import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ContentSource } from '../shared/content-source.model'; import { MetadataConfig } from '../shared/metadata-config.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index 992a29e4b8..067afcee87 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; + import { RestResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ResponseParsingService } from './parsing.service'; diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 70c45bbc2d..94e1856543 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,10 +1,13 @@ import { Injectable } from '@angular/core'; -import { compare } from 'fast-json-patch'; -import { Operation } from 'fast-json-patch'; +import { + compare, + Operation, +} from 'fast-json-patch'; + import { getClassForType } from '../cache/builders/build-decorators'; +import { TypedObject } from '../cache/typed-object.model'; import { DSpaceNotNullSerializer } from '../dspace-rest/dspace-not-null.serializer'; import { ChangeAnalyzer } from './change-analyzer'; -import { TypedObject } from '../cache/typed-object.model'; /** * A class to determine what differs between two diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index a621895633..4f0ade27b1 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -1,9 +1,13 @@ -import { compare, Operation } from 'fast-json-patch'; -import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; +import { + compare, + Operation, +} from 'fast-json-patch'; +import cloneDeep from 'lodash/cloneDeep'; + import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataMap } from '../shared/metadata.models'; -import cloneDeep from 'lodash/cloneDeep'; +import { ChangeAnalyzer } from './change-analyzer'; /** * A class to determine what differs between two diff --git a/src/app/core/data/dso-redirect.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts index 2122dc663a..b6b72583ea 100644 --- a/src/app/core/data/dso-redirect.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -1,16 +1,25 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + +import { AppConfig } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectService } from './dso-redirect.service'; -import { GetRequest, IdentifierType } from './request.models'; -import { RequestService } from './request.service'; -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 { DsoRedirectService } from './dso-redirect.service'; +import { + GetRequest, + IdentifierType, +} from './request.models'; +import { RequestService } from './request.service'; describe('DsoRedirectService', () => { let scheduler: TestScheduler; @@ -33,34 +42,35 @@ describe('DsoRedirectService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: pidLink }) + getEndpoint: cold('a', { a: pidLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); remoteData = createSuccessfulRemoteDataObject(Object.assign(new Item(), { type: 'item', - uuid: '123456789' + uuid: '123456789', })); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { - a: remoteData - }) + a: remoteData, + }), }); redirectService = jasmine.createSpyObj('redirectService', { - redirect: {} + redirect: {}, }); service = new DsoRedirectService( + environment as AppConfig, requestService, rdbService, objectCache, halService, - redirectService + redirectService, ); }); @@ -107,7 +117,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'; @@ -115,8 +125,8 @@ describe('DsoRedirectService', () => { 'dspace.entity.type': [ { language: 'en_US', - value: 'Publication' - } + value: 'Publication', + }, ], }; const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); @@ -124,7 +134,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 +143,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 +152,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..da8fe7082d 100644 --- a/src/app/core/data/dso-redirect.service.ts +++ b/src/app/core/data/dso-redirect.service.ts @@ -6,21 +6,29 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { Injectable } from '@angular/core'; +import { + Inject, + Injectable, +} from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { getDSORoute } from '../../app-routing-paths'; import { hasValue } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; import { RemoteData } from './remote-data'; import { IdentifierType } from './request.models'; import { RequestService } from './request.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -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'; const ID_ENDPOINT = 'pid'; const UUID_ENDPOINT = 'dso'; @@ -42,7 +50,7 @@ class DsoByIdOrUUIDDataService extends IdentifiableDataService { // interpolate id/uuid as query parameter (endpoint: string, resourceID: string): string => { return endpoint.replace(/{\?id}/, `?id=${resourceID}`) - .replace(/{\?uuid}/, `?uuid=${resourceID}`); + .replace(/{\?uuid}/, `?uuid=${resourceID}`); }, ); } @@ -70,11 +78,12 @@ export class DsoRedirectService { private dataService: DsoByIdOrUUIDDataService; constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - private hardRedirectService: HardRedirectService + private hardRedirectService: HardRedirectService, ) { this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService); } @@ -95,14 +104,14 @@ export class DsoRedirectService { if (response.hasSucceeded) { const dso = response.payload; if (hasValue(dso.uuid)) { - let newRoute = getDSORoute(dso); + const 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/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 74117e79d3..6c9028bf6b 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -1,13 +1,18 @@ import { Injectable } from '@angular/core'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { + DSOSuccessResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; - -import { ResponseParsingService } from './parsing.service'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 0f167ea47e..1e4809ac4b 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -1,12 +1,16 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DSpaceObjectDataService } from './dspace-object-data.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { DSpaceObjectDataService } from './dspace-object-data.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -16,7 +20,7 @@ describe('DSpaceObjectDataService', () => { let rdbService: RemoteDataBuildService; let objectCache: ObjectCacheService; const testObject = { - uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746' + uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746', } as DSpaceObject; const dsoLink = 'https://rest.api/rest/api/dso/find{?uuid}'; const requestURL = `https://rest.api/rest/api/dso/find?uuid=${testObject.uuid}`; @@ -26,18 +30,18 @@ describe('DSpaceObjectDataService', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: dsoLink }) + getEndpoint: cold('a', { a: dsoLink }), }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: requestUUID, - send: true + send: true, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: cold('a', { a: { - payload: testObject - } - }) + payload: testObject, + }, + }), }); objectCache = {} as ObjectCacheService; @@ -68,8 +72,8 @@ describe('DSpaceObjectDataService', () => { const result = service.findById(testObject.uuid); const expected = cold('a', { a: { - payload: testObject - } + payload: testObject, + }, }); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 2ad024133c..15e405a132 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@angular/core'; + import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { IdentifiableDataService } from './base/identifiable-data.service'; import { dataService } from './base/data-service.decorator'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RequestService } from './request.service'; @Injectable() @dataService(DSPACE_OBJECT) diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 500afc4aff..0177a9813a 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,23 +1,34 @@ /* eslint-disable max-classes-per-file */ -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { Serializer } from '../serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { PaginatedList, buildPaginatedList } from './paginated-list.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { environment } from '../../../environments/environment'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; -import { ParsedResponse } from '../cache/response.models'; -import { RestRequestMethod } from './rest-request-method'; -import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors'; -import { URLCombiner } from '../url-combiner/url-combiner'; + +import { environment } from '../../../environments/environment'; +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { getClassForType } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ParsedResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { + getEmbedSizeParams, + getUrlWithoutEmbedParams, +} from '../index/index.selectors'; +import { Serializer } from '../serializer'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { PageInfo } from '../shared/page-info.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + buildPaginatedList, + PaginatedList, +} from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; /** @@ -144,8 +155,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${request.href}' doesn't have a self link. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } else { @@ -155,8 +166,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService console.warn(`The response for '${urlWithoutEmbedParams}' has the self link '${response.payload._links.self.href}'. These don't match. This could mean there's an issue with the REST endpoint`); response.payload._links = Object.assign({}, response.payload._links, { self: { - href: urlWithoutEmbedParams - } + href: urlWithoutEmbedParams, + }, }); } } @@ -184,8 +195,8 @@ export class DspaceRestResponseParsingService implements ResponseParsingService protected processArray(data: any, request: RestRequest): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, request)]; - } + array = [...array, this.process(datum, request)]; + }, ); return array; } @@ -231,7 +242,7 @@ export class DspaceRestResponseParsingService implements ResponseParsingService let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index 728714876c..69e9ec2de6 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; -import { - DspaceRestResponseParsingService, - isCacheableObject -} from './dspace-rest-response-parsing.service'; +import { environment } from '../../../environments/environment'; import { hasValue } from '../../shared/empty.util'; import { getClassForType } from '../cache/builders/build-decorators'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { ParsedResponse } from '../cache/response.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { environment } from '../../../environments/environment'; import { CacheableObject } from '../cache/cacheable-object.model'; +import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { + DspaceRestResponseParsingService, + isCacheableObject, +} from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; /** @@ -56,7 +56,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing } catch (e) { console.warn(`Couldn't parse endpoint request at ${request.href}`); return new ParsedResponse(response.statusCode, undefined, { - _links: response.payload._links + _links: response.payload._links, }); } } @@ -101,7 +101,7 @@ export class EndpointMapResponseParsingService extends DspaceRestResponseParsing let dataJSON: string; if (hasValue(data._embedded)) { dataJSON = JSON.stringify(Object.assign({}, data, { - _embedded: '...' + _embedded: '...', })); } else { dataJSON = JSON.stringify(data); diff --git a/src/app/core/data/entity-type-data.service.ts b/src/app/core/data/entity-type-data.service.ts index 4020ff638d..22cf1a1706 100644 --- a/src/app/core/data/entity-type-data.service.ts +++ b/src/app/core/data/entity-type-data.service.ts @@ -1,21 +1,36 @@ -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { PaginatedList } from './paginated-list.model'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemType } from '../shared/item-relationships/item-type.model'; -import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { RelationshipTypeDataService } from './relationship-type-data.service'; -import { FindListOptions } from './find-list-options.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; import { BaseDataService } from './base/base-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RelationshipTypeDataService } from './relationship-type-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Service handling all ItemType requests @@ -48,7 +63,7 @@ export class EntityTypeDataService extends BaseDataService implements */ getRelationshipTypesEndpoint(entityTypeId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)), ); } @@ -84,7 +99,7 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorized(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipType(findListOptions).pipe( map((result: RemoteData>) => { @@ -95,7 +110,7 @@ export class EntityTypeDataService extends BaseDataService implements output = false; } return output; - }) + }), ); } @@ -118,7 +133,7 @@ export class EntityTypeDataService extends BaseDataService implements hasMoreThanOneAuthorizedImport(): Observable { const findListOptions: FindListOptions = { elementsPerPage: 2, - currentPage: 1 + currentPage: 1, }; return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( map((result: RemoteData>) => { @@ -129,7 +144,7 @@ export class EntityTypeDataService extends BaseDataService implements output = false; } return output; - }) + }), ); } diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index afd4927103..a60cef121a 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,16 +1,17 @@ -import { RequestService } from './request.service'; -import { EpersonRegistrationService } from './eperson-registration.service'; -import { RestResponse } from '../cache/response.models'; +import { HttpHeaders } from '@angular/common/http'; import { cold } from 'jasmine-marbles'; -import { PostRequest } from './request.models'; -import { Registration } from '../shared/registration.model'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { RequestEntry } from './request-entry.model'; -import { HttpHeaders } from '@angular/common/http'; + +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RestResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Registration } from '../shared/registration.model'; +import { EpersonRegistrationService } from './eperson-registration.service'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -44,7 +45,7 @@ describe('EpersonRegistrationService', () => { generateRequestId: 'request-id', send: {}, getByUUID: cold('a', - { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }) + { a: Object.assign(new RequestEntry(), { response: new RestResponse(true, 200, 'Success') }) }), }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: observableOf(rd), @@ -53,7 +54,7 @@ describe('EpersonRegistrationService', () => { service = new EpersonRegistrationService( requestService, rdbService, - halService + halService, ); }); @@ -111,9 +112,9 @@ describe('EpersonRegistrationService', () => { payload: Object.assign(new Registration(), { email: registrationWithUser.email, token: 'test-token', - user: registrationWithUser.user - }) - }) + user: registrationWithUser.user, + }), + }), })); }); @@ -128,10 +129,10 @@ describe('EpersonRegistrationService', () => { jasmine.objectContaining({ uuid: 'request-id', method: 'GET', href: 'rest-url/registrations/search/findByToken?token=test-token', - }), true + }), true, ); expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', { - a: 'rest-url/registrations/search/findByToken?token=test-token' + a: 'rest-url/registrations/search/findByToken?token=test-token', }); }); }); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 499d05af38..90a3fab83a 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -1,20 +1,33 @@ +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { GetRequest, PostRequest } from './request.models'; import { Observable } from 'rxjs'; -import { filter, find, map } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Registration } from '../shared/registration.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { ResponseParsingService } from './parsing.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { RegistrationResponseParsingService } from './registration-response-parsing.service'; -import { RemoteData } from './remote-data'; +import { + filter, + find, + map, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HttpHeaders } from '@angular/common/http'; -import { HttpParams } from '@angular/common/http'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { Registration } from '../shared/registration.model'; +import { ResponseParsingService } from './parsing.service'; +import { RegistrationResponseParsingService } from './registration-response-parsing.service'; +import { RemoteData } from './remote-data'; +import { + GetRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -81,11 +94,11 @@ export class EpersonRegistrationService { map((href: string) => { const request = new PostRequest(requestId, href, registration, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } @@ -105,7 +118,7 @@ export class EpersonRegistrationService { Object.assign(request, { getResponseParser(): GenericConstructor { return RegistrationResponseParsingService; - } + }, }); this.requestService.send(request, true); }); @@ -117,7 +130,7 @@ export class EpersonRegistrationService { } else { return rd; } - }) + }), ); } diff --git a/src/app/core/data/external-source-data.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts index 723d7f9bed..5e643cc549 100644 --- a/src/app/core/data/external-source-data.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -1,11 +1,12 @@ -import { ExternalSourceDataService } from './external-source-data.service'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { of as observableOf } from 'rxjs'; -import { GetRequest } from './request.models'; import { testSearchDataImplementation } from './base/search-data.spec'; -import { take } from 'rxjs/operators'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { GetRequest } from './request.models'; describe('ExternalSourceService', () => { let service: ExternalSourceDataService; @@ -22,10 +23,10 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0001' - } - ] - } + value: 'https://orcid.org/0001-0001-0001-0001', + }, + ], + }, }), Object.assign(new ExternalSourceEntry(), { id: '0001-0001-0001-0002', @@ -34,20 +35,20 @@ describe('ExternalSourceService', () => { metadata: { 'dc.identifier.uri': [ { - value: 'https://orcid.org/0001-0001-0001-0002' - } - ] - } - }) + value: 'https://orcid.org/0001-0001-0001-0002', + }, + ], + }, + }), ]; function init() { requestService = jasmine.createSpyObj('requestService', { generateRequestId: 'request-uuid', - send: {} + send: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) + buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)), }); halService = jasmine.createSpyObj('halService', { getEndpoint: observableOf('external-sources-REST-endpoint'), @@ -96,12 +97,6 @@ describe('ExternalSourceService', () => { result.pipe(take(1)).subscribe(); expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), false); }); - - it('should return the entries', () => { - result.subscribe((resultRD) => { - expect(resultRD.payload.page).toBe(entries); - }); - }); }); }); }); diff --git a/src/app/core/data/external-source-data.service.ts b/src/app/core/data/external-source-data.service.ts index 02c5e4a53c..16eaeb9321 100644 --- a/src/app/core/data/external-source-data.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -1,20 +1,32 @@ import { Injectable } from '@angular/core'; -import { ExternalSource } from '../shared/external-source.model'; -import { RequestService } from './request.service'; +import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmptyOperator, +} from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list.model'; +import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { FindListOptions } from './find-list-options.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IdentifiableDataService } from './base/identifiable-data.service'; -import { SearchData, SearchDataImpl } from './base/search-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service handling all external source requests @@ -50,7 +62,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.getBrowseEndpoint().pipe( map((href) => this.getIDHref(href, externalSourceId)), - switchMap((href) => this.halService.getEndpoint('entries', href)) + switchMap((href) => this.halService.getEndpoint('entries', href)), ); } @@ -78,7 +90,7 @@ export class ExternalSourceDataService extends IdentifiableDataService { return this.findListByHref(href$, undefined, !hasCachedErrorResponse, reRequestOnStale, ...linksToFollow as any); - }) + }), ) as any; } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 3e4493c32b..b4086428ea 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@angular/core'; + +import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model'; import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; -import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; import { RestRequest } from './rest-request.model'; @Injectable() @@ -16,19 +17,19 @@ export class FacetConfigResponseParsingService extends DspaceRestResponseParsing const filters = serializer.deserializeArray(config); const _links = { - self: data.payload._links.self + self: data.payload._links.self, }; // fill in the missing links section filters.forEach((filterConfig: SearchFilterConfig) => { _links[filterConfig.name] = { - href: filterConfig._links.self.href + href: filterConfig._links.self.href, }; }); const facetConfigResponse = Object.assign(new FacetConfigResponse(), { filters, - _links + _links, }); this.addToObjectCache(facetConfigResponse, request, data); diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 0911ed5073..871f2a4965 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@angular/core'; + import { FacetValue } from '../../shared/search/models/facet-value.model'; -import { ParsedResponse } from '../cache/response.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { FacetValues } from '../../shared/search/models/facet-values.model'; +import { ParsedResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index ae44d590a4..4048efe4ff 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -1,18 +1,26 @@ -import { AuthorizationDataService } from './authorization-data.service'; -import { SiteDataService } from '../site-data.service'; -import { Site } from '../../shared/site.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { FeatureID } from './feature-id'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; + import { hasValue } from '../../../shared/empty.util'; -import { RequestParam } from '../../cache/models/request-param.model'; -import { Authorization } from '../../shared/authorization.model'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { Feature } from '../../shared/feature.model'; -import { FindListOptions } from '../find-list-options.model'; -import { testSearchDataImplementation } from '../base/search-data.spec'; import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { Authorization } from '../../shared/authorization.model'; +import { Feature } from '../../shared/feature.model'; +import { Site } from '../../shared/site.model'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { FindListOptions } from '../find-list-options.model'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationDataService } from './authorization-data.service'; +import { FeatureID } from './feature-id'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; @@ -23,19 +31,19 @@ describe('AuthorizationDataService', () => { let ePerson: EPerson; const requestService = jasmine.createSpyObj('requestService', { - setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring') + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring'), }); function init() { site = Object.assign(new Site(), { id: 'test-site', _links: { - self: { href: 'test-site-href' } - } + self: { href: 'test-site-href' }, + }, }); ePerson = Object.assign(new EPerson(), { id: 'test-eperson', - uuid: 'test-eperson' + uuid: 'test-eperson', }); siteService = jasmine.createSpyObj('siteService', { find: observableOf(site), @@ -157,26 +165,26 @@ describe('AuthorizationDataService', () => { const validPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: featureID - })) - }) + id: featureID, + })), + }), ]; const invalidPayload = [ Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'invalid-feature' - })) + id: 'invalid-feature', + })), }), Object.assign(new Authorization(), { feature: createSuccessfulRemoteDataObject$(Object.assign(new Feature(), { - id: 'another-invalid-feature' - })) - }) + id: 'another-invalid-feature', + })), + }), ]; const emptyPayload = []; 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..cd8705d2fb 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -1,26 +1,44 @@ -import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; -import { AUTHORIZATION } from '../../shared/authorization.resource-type'; -import { Authorization } from '../../shared/authorization.model'; -import { RequestService } from '../request.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + catchError, + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; +import { + followLink, + FollowLinkConfig, +} from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { SiteDataService } from '../site-data.service'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { RemoteData } from '../remote-data'; -import { PaginatedList } from '../paginated-list.model'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Authorization } from '../../shared/authorization.model'; +import { AUTHORIZATION } from '../../shared/authorization.resource-type'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { BaseDataService } from '../base/base-data.service'; +import { dataService } from '../base/data-service.decorator'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { SiteDataService } from '../site-data.service'; import { AuthorizationSearchParams } from './authorization-search-params'; import { oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; -import { FindListOptions } from '../find-list-options.model'; -import { BaseDataService } from '../base/base-data.service'; -import { SearchData, SearchDataImpl } from '../base/search-data'; -import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Authorization}s from the REST API @@ -74,8 +92,8 @@ export class AuthorizationDataService extends BaseDataService imp return []; } }), - catchError(() => observableOf(false)), - oneAuthorizationMatchesFeature(featureId) + catchError(() => observableOf([])), + oneAuthorizationMatchesFeature(featureId), ); } @@ -100,7 +118,7 @@ export class AuthorizationDataService extends BaseDataService imp switchMap((url) => { if (hasNoValue(url)) { return this.siteService.find().pipe( - map((site) => site.self) + map((site) => site.self), ); } else { return observableOf(url); @@ -112,7 +130,7 @@ export class AuthorizationDataService extends BaseDataService imp map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - }) + }), ); this.addDependency(out$, objectUrl$); diff --git a/src/app/core/data/feature-authorization/authorization-utils.ts b/src/app/core/data/feature-authorization/authorization-utils.ts index d1b65f6123..f763b1a38d 100644 --- a/src/app/core/data/feature-authorization/authorization-utils.ts +++ b/src/app/core/data/feature-authorization/authorization-utils.ts @@ -1,13 +1,25 @@ -import { map, switchMap } from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { AuthorizationSearchParams } from './authorization-search-params'; -import { SiteDataService } from '../site-data.service'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { AuthService } from '../../auth/auth.service'; import { Authorization } from '../../shared/authorization.model'; import { Feature } from '../../shared/feature.model'; -import { FeatureID } from './feature-id'; import { getFirstSucceededRemoteDataPayload } from '../../shared/operators'; +import { SiteDataService } from '../site-data.service'; +import { AuthorizationSearchParams } from './authorization-search-params'; +import { FeatureID } from './feature-id'; /** * Operator accepting {@link AuthorizationSearchParams} and adding the current {@link Site}'s selflink to the parameter's @@ -20,12 +32,12 @@ export const addSiteObjectUrlIfEmpty = (siteService: SiteDataService) => switchMap((params: AuthorizationSearchParams) => { if (hasNoValue(params.objectUrl)) { return siteService.find().pipe( - map((site) => Object.assign({}, params, { objectUrl: site.self })) + map((site) => Object.assign({}, params, { objectUrl: site.self })), ); } else { return observableOf(params); } - }) + }), ); /** @@ -42,17 +54,17 @@ export const addAuthenticatedUserUuidIfEmpty = (authService: AuthService) => switchMap((authenticated) => { if (authenticated) { return authService.getAuthenticatedUserFromStore().pipe( - map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })) + map((ePerson) => Object.assign({}, params, { ePersonUuid: ePerson.uuid })), ); } else { return observableOf(params); } - }) + }), ); } else { return observableOf(params); } - }) + }), ); /** @@ -68,16 +80,16 @@ 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() - )) - ); + getFirstSucceededRemoteDataPayload(), + )), + ]); } else { return observableOf([]); } }), - map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID).length > 0) + map((features: Feature[]) => features.filter((feature: Feature) => feature.id === featureID).length > 0), ); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts index b41a322cb6..5af9f18b33 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Collection administrator */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts index 2ab77a00cc..2092fce110 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/community-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user * isn't a Community administrator */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CommunityAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts index 6c1f330c69..234c2e1628 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.spec.ts @@ -1,12 +1,21 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + Resolve, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; -import { FeatureID } from '../feature-id'; import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard'; /** * Test implementation of abstract class DsoPageSingleFeatureGuard @@ -37,30 +46,30 @@ describe('DsoPageSingleFeatureGuard', () => { function init() { object = { - self: 'test-selflink' + self: 'test-selflink', } as DSpaceObject; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + resolve: createSuccessfulRemoteDataObject$(object), }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; guard = new DsoPageSingleFeatureGuardImpl(resolver, authorizationService, router, authService, undefined); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts index 3fc90f9069..1f75df846b 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard.ts @@ -1,9 +1,13 @@ -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { FeatureID } from '../feature-id'; -import { map } from 'rxjs/operators'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { FeatureID } from '../feature-id'; +import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; /** * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts index 071b1b0731..61e236188d 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.spec.ts @@ -1,11 +1,20 @@ -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; -import { RemoteData } from '../../remote-data'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + Resolve, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { FeatureID } from '../feature-id'; import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard'; /** @@ -37,30 +46,30 @@ describe('DsoPageSomeFeatureGuard', () => { function init() { object = { - self: 'test-selflink' + self: 'test-selflink', } as DSpaceObject; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); resolver = jasmine.createSpyObj('resolver', { - resolve: createSuccessfulRemoteDataObject$(object) + resolve: createSuccessfulRemoteDataObject$(object), }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); parentRoute = { params: { - id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0' - } + id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0', + }, }; route = { params: { }, - parent: parentRoute + parent: parentRoute, }; guard = new DsoPageSomeFeatureGuardImpl(resolver, authorizationService, router, authService, []); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts index 8683709345..eff2da2102 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard.ts @@ -1,12 +1,21 @@ -import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Resolve, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../../shared/empty.util'; +import { AuthService } from '../../../auth/auth.service'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; import { RemoteData } from '../../remote-data'; import { AuthorizationDataService } from '../authorization-data.service'; -import { Observable } from 'rxjs'; -import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; -import { map } from 'rxjs/operators'; -import { DSpaceObject } from '../../../shared/dspace-object.model'; -import { AuthService } from '../../../auth/auth.service'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** @@ -28,7 +37,7 @@ export abstract class DsoPageSomeFeatureGuard extends So const routeWithObjectID = this.getRouteWithDSOId(route); return (this.resolver.resolve(routeWithObjectID, state) as Observable>).pipe( getAllSucceededRemoteDataPayload(), - map((dso) => dso.self) + map((dso) => dso.self), ); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts index 5afd572326..5f32e26851 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/group-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts index 635aa3530b..e789f8c473 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.spec.ts @@ -1,9 +1,17 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Test implementation of abstract class SingleFeatureAuthorizationGuard @@ -48,13 +56,13 @@ describe('SingleFeatureAuthorizationGuard', () => { ePersonUuid = 'fake-eperson-uuid'; authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts index cb71d2f418..cd9f615aa7 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard.ts @@ -1,7 +1,11 @@ -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { FeatureID } from '../feature-id'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; import { Observable } from 'rxjs'; -import { map} from 'rxjs/operators'; +import { map } from 'rxjs/operators'; + +import { FeatureID } from '../feature-id'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index cc6f50c161..9a7c9de5c4 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -1,17 +1,25 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { FeatureID } from '../feature-id'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator * rights to the {@link Site} */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts index bdbb8250e2..bde2d1c14e 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -1,17 +1,25 @@ -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; import { Injectable } from '@angular/core'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { FeatureID } from '../feature-id'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration * rights to the {@link Site} */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class SiteRegisterGuard extends SingleFeatureAuthorizationGuard { constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts index 90153d2d14..53d77cadad 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.spec.ts @@ -1,8 +1,16 @@ +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../../auth/auth.service'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; -import { Observable, of as observableOf } from 'rxjs'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { AuthService } from '../../../auth/auth.service'; import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard'; /** @@ -52,13 +60,13 @@ describe('SomeFeatureAuthorizationGuard', () => { authorizationService = Object.assign({ isAuthorized(featureId?: FeatureID): Observable { return observableOf(authorizedFeatureIds.indexOf(featureId) > -1); - } + }, }); router = jasmine.createSpyObj('router', { - parseUrl: {} + parseUrl: {}, }); authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + isAuthenticated: observableOf(true), }); guard = new SomeFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureIds, objectUrl, ePersonUuid); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts index b909640ea6..0849c5a96a 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts @@ -1,10 +1,21 @@ -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { FeatureID } from '../feature-id'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, +} from 'rxjs'; import { switchMap } from 'rxjs/operators'; + import { AuthService } from '../../../auth/auth.service'; import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authorized.operators'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { FeatureID } from '../feature-id'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -24,9 +35,9 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( switchMap(([featureIDs, objectUrl, ePersonUuid]) => - observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))) + observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))), ), - returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url) + returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url), ); } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts index 680495686e..b301d550a1 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts @@ -1,27 +1,35 @@ import { Injectable } from '@angular/core'; -import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; -import { AuthorizationDataService } from '../authorization-data.service'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { AuthService } from '../../../auth/auth.service'; -import { Observable, of as observableOf } from 'rxjs'; +import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group * management rights */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class StatisticsAdministratorGuard extends SingleFeatureAuthorizationGuard { - constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { - super(authorizationService, router, authService); - } + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { + super(authorizationService, router, authService); + } - /** + /** * Check group management rights */ - getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return observableOf(FeatureID.CanViewUsageStatistics); - } + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.CanViewUsageStatistics); + } } diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index eda8791153..184e8c0be9 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@angular/core'; -import { FEATURE } from '../../shared/feature.resource-type'; -import { Feature } from '../../shared/feature.model'; -import { RequestService } from '../request.service'; + import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Feature } from '../../shared/feature.model'; +import { FEATURE } from '../../shared/feature.resource-type'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { BaseDataService } from '../base/base-data.service'; import { dataService } from '../base/data-service.decorator'; +import { RequestService } from '../request.service'; /** * A service to retrieve {@link Feature}s from the REST API diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 8fef45a953..3d5803b018 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,4 +34,7 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', + CoarNotifyEnabled = 'coarNotifyEnabled', + CanSeeQA = 'canSeeQA', + EPersonForgotPassword = 'epersonForgotPassword' } diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts index ac0e96a2e6..6966e6a631 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.spec.ts @@ -1,10 +1,10 @@ -import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { FilteredDiscoveryPageResponseParsingService } from './filtered-discovery-page-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { GetRequest } from './request.models'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { FilteredDiscoveryQueryResponse } from '../cache/response.models'; describe('FilteredDiscoveryPageResponseParsingService', () => { let service: FilteredDiscoveryPageResponseParsingService; @@ -17,15 +17,15 @@ describe('FilteredDiscoveryPageResponseParsingService', () => { const request = Object.assign(new GetRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/path'), { getResponseParser(): GenericConstructor { return FilteredDiscoveryPageResponseParsingService; - } + }, }); const mockResponse = { payload: { - 'discovery-query': 'query' + 'discovery-query': 'query', }, statusCode: 200, - statusText: 'OK' + statusText: 'OK', } as RawRestResponse; it('should return a FilteredDiscoveryQueryResponse containing the correct query', () => { diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts index da7a21c488..fed0d70c58 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -1,9 +1,13 @@ import { Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; + +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + FilteredDiscoveryQueryResponse, + RestResponse, +} from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; +import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './rest-request.model'; /** diff --git a/src/app/core/data/find-list-options.model.ts b/src/app/core/data/find-list-options.model.ts index dc567d4b53..78fe26fcab 100644 --- a/src/app/core/data/find-list-options.model.ts +++ b/src/app/core/data/find-list-options.model.ts @@ -1,15 +1,15 @@ -import { SortOptions } from '../cache/models/sort-options.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { SortOptions } from '../cache/models/sort-options.model'; /** * The options for a find list request */ export class FindListOptions { - scopeID?: string; - elementsPerPage?: number; - currentPage?: number; - sort?: SortOptions; - searchParams?: RequestParam[]; - startsWith?: string; - fetchThumbnail?: boolean; + scopeID?: string; + elementsPerPage?: number; + currentPage?: number; + sort?: SortOptions; + searchParams?: RequestParam[]; + startsWith?: string; + fetchThumbnail?: boolean; } diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index bf7d2890ea..cba85d5de6 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,11 @@ -import { HrefOnlyDataService } from './href-only-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FindListOptions } from './find-list-options.model'; +import { + followLink, + FollowLinkConfig, +} from '../../shared/utils/follow-link-config.model'; import { BaseDataService } from './base/base-data.service'; +import { FindListOptions } from './find-list-options.model'; +import { HrefOnlyDataService } from './href-only-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -23,60 +26,60 @@ describe(`HrefOnlyDataService`, () => { expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); - describe(`findByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); + describe(`findByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); - it(`should forward to findByHref on the internal DataService`, () => { - service.findByHref(href, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); - }); + it(`should forward to findByHref on the internal DataService`, () => { + service.findByHref(href, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); + }); - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); - }); - }); - - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); - }); + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), true, jasmine.anything()); }); }); - describe(`findListByHref`, () => { - beforeEach(() => { - spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); - }); - - it(`should delegate to findListByHref on the internal DataService`, () => { - service.findListByHref(href, findListOptions, false, false, ...followLinks); - expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); - }); - - describe(`when findListOptions is omitted`, () => { - it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); - }); - }); - - describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); - }); - }); - - describe(`when reRequestOnStale is omitted`, () => { - it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findListByHref(href); - expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); - }); + describe(`when reRequestOnStale is omitted`, () => { + it(`should call findByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true); }); }); + }); + + describe(`findListByHref`, () => { + beforeEach(() => { + spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + }); + + it(`should delegate to findListByHref on the internal DataService`, () => { + service.findListByHref(href, findListOptions, false, false, ...followLinks); + expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); + }); + + describe(`when findListOptions is omitted`, () => { + it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); + }); + }); + + describe(`when useCachedVersionIfAvailable is omitted`, () => { + it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); + }); + }); + + describe(`when reRequestOnStale is omitted`, () => { + it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findListByHref(href); + expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); + }); + }); + }); }); diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 0a765de101..83fb431e9b 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,24 +1,25 @@ -import { RequestService } from './request.service'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../cache/cacheable-object.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Injectable } from '@angular/core'; -import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RemoteData } from './remote-data'; -import { Observable } from 'rxjs'; -import { PaginatedList } from './paginated-list.model'; import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; import { LICENSE } from '../shared/license.resource-type'; -import { CacheableObject } from '../cache/cacheable-object.model'; -import { FindListOptions } from './find-list-options.model'; +import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; import { BaseDataService } from './base/base-data.service'; -import { HALDataService } from './base/hal-data-service.interface'; import { dataService } from './base/data-service.decorator'; +import { HALDataService } from './base/hal-data-service.interface'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** - * A DataService with only findByHref methods. Its purpose is to be used for resources that don't - * need to be retrieved by ID, or have any way to update them, but require a DataService in order + * A UpdateDataServiceImpl with only findByHref methods. Its purpose is to be used for resources that don't + * need to be retrieved by ID, or have any way to update them, but require a UpdateDataServiceImpl in order * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types diff --git a/src/app/core/data/identifier-data.service.ts b/src/app/core/data/identifier-data.service.ts index 03422dadfb..bf6172fd47 100644 --- a/src/app/core/data/identifier-data.service.ts +++ b/src/app/core/data/identifier-data.service.ts @@ -1,27 +1,35 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + HttpClient, + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from './base/data-service.decorator'; +import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; +import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { BaseDataService } from './base/base-data.service'; -import { RequestService } from './request.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CoreState } from '../core-state.model'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { Item } from '../shared/item.model'; -import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type'; -import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map, switchMap } from 'rxjs/operators'; -import {ConfigurationProperty} from '../shared/configuration-property.model'; -import {ConfigurationDataService} from './configuration-data.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { PostRequest } from './request.models'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; +import { BaseDataService } from './base/base-data.service'; +import { dataService } from './base/data-service.decorator'; +import { ConfigurationDataService } from './configuration-data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; import { RestRequest } from './rest-request.model'; /** @@ -61,7 +69,7 @@ export class IdentifierDataService extends BaseDataService { public getIdentifierRegistrationConfiguration(): Observable { return this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe( getFirstCompletedRemoteData(), - map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []), ); } @@ -79,7 +87,7 @@ export class IdentifierDataService extends BaseDataService { return new PostRequest(requestId, endpointURL, item._links.self.href, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>), ); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 2c20ed0fb6..915c065a9e 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,25 +1,32 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { cold, getTestScheduler } from 'jasmine-marbles'; +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; + +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BrowseService } from '../browse/browse.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; -import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; -import { RequestService } from './request.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; -import { FindListOptions } from './find-list-options.model'; -import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { FindListOptions } from './find-list-options.model'; +import { ItemDataService } from './item-data.service'; +import { + DeleteRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -46,7 +53,7 @@ describe('ItemDataService', () => { const objectCache = {} as ObjectCacheService; const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { - findByHref: {} + findByHref: {}, }); const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -54,8 +61,8 @@ describe('ItemDataService', () => { scopeID: scopeID, sort: { field: '', - direction: undefined - } + direction: undefined, + }, }); const browsesEndpoint = 'https://rest.api/discover/browses'; @@ -73,7 +80,7 @@ describe('ItemDataService', () => { cold('--a-', { a: itemBrowseEndpoint }) : cold('--#-', undefined, browseError); return jasmine.createSpyObj('bs', { - getBrowseURLFor: obs + getBrowseURLFor: obs, }); } @@ -158,7 +165,7 @@ describe('ItemDataService', () => { const externalSourceEntry = Object.assign(new ExternalSourceEntry(), { display: 'John, Doe', value: 'John, Doe', - _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } } + _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } }, }); beforeEach(() => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c3fa84dd6c..f071f6e1b8 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -8,44 +8,73 @@ /* eslint-disable max-classes-per-file */ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { + distinctUntilChanged, + filter, + find, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { + hasValue, + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { Bundle } from '../shared/bundle.model'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { DeleteRequest, GetRequest, PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Bundle } from '../shared/bundle.model'; import { MetadataMap } from '../shared/metadata.models'; -import { BundleDataService } from './bundle-data.service'; -import { Operation } from 'fast-json-patch'; import { NoContent } from '../shared/NoContent.model'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { ResponseParsingService } from './parsing.service'; -import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; import { sendRequest } from '../shared/request.operators'; -import { RestRequest } from './rest-request.model'; -import { FindListOptions } from './find-list-options.model'; -import { ConstructIdEndpoint, IdentifiableDataService } from './base/identifiable-data.service'; -import { PatchData, PatchDataImpl } from './base/patch-data'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { RestRequestMethod } from './rest-request-method'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { RequestParam } from '../cache/models/request-param.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; import { dataService } from './base/data-service.decorator'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + ConstructIdEndpoint, + IdentifiableDataService, +} from './base/identifiable-data.service'; +import { + PatchData, + PatchDataImpl, +} from './base/patch-data'; +import { BundleDataService } from './bundle-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { ResponseParsingService } from './parsing.service'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + GetRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; +import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; +import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; /** * An abstract service for CRUD operations on Items @@ -140,7 +169,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService return new PostRequest(this.requestService.generateRequestId(), endpointURL, collectionHref, options); }), sendRequest(this.requestService), - switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)) + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), ); } @@ -152,7 +181,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public setWithDrawn(item: Item, withdrawn: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/withdrawn', value: withdrawn + op: 'replace', path: '/withdrawn', value: withdrawn, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -166,7 +195,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public setDiscoverable(item: Item, discoverable: boolean): Observable> { const patchOperation = { - op: 'replace', path: '/discoverable', value: discoverable + op: 'replace', path: '/discoverable', value: discoverable, } as Operation; this.requestService.removeByHrefSubstring('/discover'); @@ -180,7 +209,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundlesEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)), ); } @@ -191,10 +220,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { const hrefObs = this.getBundlesEndpoint(itemId).pipe( - map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href), ); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request); @@ -215,11 +244,11 @@ export abstract class BaseItemDataService extends IdentifiableDataService const bundleJson = { name: bundleName, - metadata: metadata ? metadata : {} + metadata: metadata ? metadata : {}, }; hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -238,7 +267,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getIdentifiersEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`)), ); } @@ -249,7 +278,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService public getMoveItemEndpoint(itemId: string, inheritPolicies: boolean): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`) + map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`), ); } @@ -275,10 +304,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService // TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code getResponseParser(): GenericConstructor { return StatusCodeOnlyResponseParsingService; - } + }, }); return request; - }) + }), ).subscribe((request) => { this.requestService.send(request); }); @@ -305,7 +334,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, externalSourceEntry._links.self.href, options); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); @@ -317,7 +346,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService */ public getBitstreamsEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) + switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)), ); } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index a5d1872510..68577ae6e2 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -1,12 +1,13 @@ -import { ItemRequestDataService } from './item-request-data.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { of as observableOf } from 'rxjs'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { ItemRequest } from '../shared/item-request.model'; -import { PostRequest } from './request.models'; + import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { ItemRequestDataService } from './item-request-data.service'; +import { PostRequest } from './request.models'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; describe('ItemRequestDataService', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index ff6025f7ac..5c85ed1471 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -1,20 +1,32 @@ +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { RemoteData } from './remote-data'; -import { PostRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { ItemRequest } from '../shared/item-request.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HttpHeaders } from '@angular/common/http'; +import { + distinctUntilChanged, + filter, + find, + map, +} from 'rxjs/operators'; + import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { sendRequest } from '../shared/request.operators'; import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RemoteData } from './remote-data'; +import { + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint @@ -60,11 +72,11 @@ export class ItemRequestDataService extends IdentifiableDataService map((href: string) => { const request = new PostRequest(requestId, href, itemRequest); this.requestService.send(request); - }) + }), ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData() + getFirstCompletedRemoteData(), ); } diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 16cf0dbd99..27db819861 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -1,22 +1,26 @@ -import { ItemTemplateDataService } from './item-template-data.service'; -import { RestResponse } from '../cache/response.models'; -import { RequestService } from './request.service'; -import { Observable, of as observableOf } from 'rxjs'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { CollectionDataService } from './collection-data.service'; -import { RestRequestMethod } from './rest-request-method'; -import { Item } from '../shared/item.model'; -import { RestRequest } from './rest-request.model'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core-state.model'; -import { RequestEntry } from './request-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { CollectionDataService } from './collection-data.service'; +import { ItemTemplateDataService } from './item-template-data.service'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RestRequest } from './rest-request.model'; +import { RestRequestMethod } from './rest-request-method'; import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { @@ -46,7 +50,7 @@ describe('ItemTemplateDataService', () => { }, commit(method?: RestRequestMethod) { // Do nothing - } + }, } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; @@ -62,18 +66,18 @@ describe('ItemTemplateDataService', () => { const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: itemEndpoint }); - } + }, } as HALEndpointService; const notificationsService = {} as NotificationsService; const comparator = { diff(first, second) { return [{}]; - } + }, } as any; const collectionService = { getIDHrefObs(id): Observable { return observableOf(collectionEndpoint); - } + }, } as CollectionDataService; function initTestService() { diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index 634c966dba..0ee362c71a 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,22 +1,23 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { BaseItemDataService } from './item-data.service'; -import { Item } from '../shared/item.model'; -import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RequestService } from './request.service'; +import { switchMap } from 'rxjs/operators'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { BrowseService } from '../browse/browse.service'; -import { CollectionDataService } from './collection-data.service'; -import { switchMap } from 'rxjs/operators'; -import { BundleDataService } from './bundle-data.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; +import { Item } from '../shared/item.model'; import { CreateDataImpl } from './base/create-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { BundleDataService } from './bundle-data.service'; +import { CollectionDataService } from './collection-data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { BaseItemDataService } from './item-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Data service for interacting with Item templates via their Collection diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 58598b9870..93b4ed6696 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -1,18 +1,22 @@ -import { LookupRelationService } from './lookup-relation.service'; -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { buildPaginatedList } from './paginated-list.model'; -import { PageInfo } from '../shared/page-info.model'; -import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { SearchResult } from '../../shared/search/models/search-result.model'; -import { Item } from '../shared/item.model'; -import { skip, take } from 'rxjs/operators'; -import { ExternalSource } from '../shared/external-source.model'; -import { RequestService } from './request.service'; import { of as observableOf } from 'rxjs'; +import { + skip, + take, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { ExternalSource } from '../shared/external-source.model'; +import { Item } from '../shared/item.model'; +import { PageInfo } from '../shared/page-info.model'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { LookupRelationService } from './lookup-relation.service'; +import { buildPaginatedList } from './paginated-list.model'; +import { RequestService } from './request.service'; describe('LookupRelationService', () => { let service: LookupRelationService; @@ -24,20 +28,20 @@ describe('LookupRelationService', () => { const optionsWithQuery = new PaginatedSearchOptions({ query: 'test-query' }); const relationship = Object.assign(new RelationshipOptions(), { filter: 'test-filter', - configuration: 'test-configuration' + configuration: 'test-configuration', }); const localResults = [ Object.assign(new SearchResult(), { indexableObject: Object.assign(new Item(), { uuid: 'test-item-uuid', - handle: 'test-item-handle' - }) - }) + handle: 'test-item-handle', + }), + }), ]; const externalSource = Object.assign(new ExternalSource(), { id: 'orcidV2', name: 'orcidV2', - hierarchical: false + hierarchical: false, }); const searchServiceEndpoint = 'http://test-rest.com/server/api/core/search'; @@ -47,12 +51,12 @@ describe('LookupRelationService', () => { elementsPerPage: 1, totalElements: totalExternal, totalPages: totalExternal, - currentPage: 1 - }), [{}])) + currentPage: 1, + }), [{}])), }); searchService = jasmine.createSpyObj('searchService', { search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)), - getEndpoint: observableOf(searchServiceEndpoint) + getEndpoint: observableOf(searchServiceEndpoint), }); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); service = new LookupRelationService(externalSourceService, searchService, requestService); @@ -77,7 +81,7 @@ describe('LookupRelationService', () => { it('should set the searchConfig to contain a fixedFilter and configuration', () => { expect(service.searchConfig).toEqual(Object.assign(new PaginatedSearchOptions({}), optionsWithQuery, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, )); }); }); diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7a6bc2358b..31b9ed845c 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,19 +1,34 @@ -import { ExternalSourceDataService } from './external-source-data.service'; -import { SearchService } from '../shared/search/search.service'; -import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { + Observable, + ReplaySubject, +} from 'rxjs'; +import { + concat, + distinctUntilChanged, + map, + multicast, + startWith, + take, + takeWhile, +} from 'rxjs/operators'; + +import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { Observable, ReplaySubject } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list.model'; import { SearchResult } from '../../shared/search/models/search-result.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { Item } from '../shared/item.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { Injectable } from '@angular/core'; import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { Item } from '../shared/item.model'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; +import { SearchService } from '../shared/search/search.service'; +import { ExternalSourceDataService } from './external-source-data.service'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; /** @@ -31,7 +46,7 @@ export class LookupRelationService { */ private singleResultOptions = Object.assign(new PaginationComponentOptions(), { id: 'single-result-options', - pageSize: 1 + pageSize: 1, }); constructor(protected externalSourceService: ExternalSourceDataService, @@ -47,7 +62,7 @@ export class LookupRelationService { */ getLocalResults(relationship: RelationshipOptions, searchOptions: PaginatedSearchOptions, setSearchConfig = true): Observable>>> { const newConfig = Object.assign(new PaginatedSearchOptions({}), searchOptions, - { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration } + { fixedFilter: relationship.filter, configuration: relationship.searchConfiguration }, ); if (setSearchConfig) { this.searchConfig = newConfig; @@ -59,8 +74,8 @@ export class LookupRelationService { () => new ReplaySubject(1), (subject) => subject.pipe( takeWhile((rd: RemoteData>>) => rd.isLoading), - concat(subject.pipe(take(1))) - ) + concat(subject.pipe(take(1))), + ), ) as any , ) as Observable>>>; @@ -76,7 +91,7 @@ export class LookupRelationService { getAllSucceededRemoteData(), getRemoteDataPayload(), map((results: PaginatedList>) => results.totalElements), - startWith(0) + startWith(0), ); } @@ -91,7 +106,7 @@ export class LookupRelationService { getRemoteDataPayload(), map((results: PaginatedList) => results.totalElements), startWith(0), - distinctUntilChanged() + distinctUntilChanged(), ); } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 1ce078f5d5..8d65038060 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -1,20 +1,21 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataFieldDataService } from './metadata-field-data.service'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { RequestParam } from '../cache/models/request-param.model'; -import { FindListOptions } from './find-list-options.model'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testCreateDataImplementation } from './base/create-data.spec'; -import { testSearchDataImplementation } from './base/search-data.spec'; -import { testPutDataImplementation } from './base/put-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { FindListOptions } from './find-list-options.model'; +import { MetadataFieldDataService } from './metadata-field-data.service'; +import { RequestService } from './request.service'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -31,8 +32,8 @@ describe('MetadataFieldDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); requestService = jasmine.createSpyObj('requestService', { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', @@ -73,7 +74,7 @@ describe('MetadataFieldDataService', () => { it('should call searchBy with the correct arguments', () => { metadataFieldService.findBySchema(schema); const expectedOptions = Object.assign(new FindListOptions(), { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); expect(metadataFieldService.searchBy).toHaveBeenCalledWith('bySchema', expectedOptions, true, true); }); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index d05e3533d3..c6a08fa82b 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,27 +1,40 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; +import { dataService } from './base/data-service.decorator'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PutData, + PutDataImpl, +} from './base/put-data'; +import { + SearchData, + SearchDataImpl, +} from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; -import { MetadataField } from '../metadata/metadata-field.model'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { RequestParam } from '../cache/models/request-param.model'; -import { FindListOptions } from './find-list-options.model'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { PutData, PutDataImpl } from './base/put-data'; -import { CreateData, CreateDataImpl } from './base/create-data'; -import { NoContent } from '../shared/NoContent.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { dataService } from './base/data-service.decorator'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts index 1bcf4e1104..02fbc016e7 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -1,16 +1,20 @@ -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { MetadataSchemaDataService } from './metadata-schema-data.service'; import { of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { CreateRequest, PutRequest } from './request.models'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; + import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestResponse } from '../cache/response.models'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { MetadataSchemaDataService } from './metadata-schema-data.service'; +import { + CreateRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; describe('MetadataSchemaDataService', () => { let metadataSchemaService: MetadataSchemaDataService; @@ -61,8 +65,8 @@ describe('MetadataSchemaDataService', () => { prefix: 'dc', namespace: 'namespace', _links: { - self: { href: 'selflink' } - } + self: { href: 'selflink' }, + }, }); }); @@ -78,7 +82,7 @@ describe('MetadataSchemaDataService', () => { describe('called with an existing metadata schema', () => { beforeEach(() => { schema = Object.assign(schema, { - id: 'id-of-existing-schema' + id: 'id-of-existing-schema', }); }); diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 6bd633b8c6..8054dcf657 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,25 +1,38 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from './request.service'; -import { Observable } from 'rxjs'; -import { hasValue } from '../../shared/empty.util'; -import { tap } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { PutData, PutDataImpl } from './base/put-data'; -import { CreateData, CreateDataImpl } from './base/create-data'; import { NoContent } from '../shared/NoContent.model'; -import { FindAllData, FindAllDataImpl } from './base/find-all-data'; -import { FindListOptions } from './find-list-options.model'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { PaginatedList } from './paginated-list.model'; -import { IdentifiableDataService } from './base/identifiable-data.service'; -import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { + CreateData, + CreateDataImpl, +} from './base/create-data'; import { dataService } from './base/data-service.decorator'; +import { + DeleteData, + DeleteDataImpl, +} from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { + PutData, + PutDataImpl, +} from './base/put-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index e46e319149..8e7532a58e 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,10 +1,14 @@ import { Injectable } from '@angular/core'; + +import { hasValue } from '../../shared/empty.util'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { hasValue } from '../../shared/empty.util'; -import { SearchObjects } from '../../shared/search/models/search-objects.model'; -import { MetadataMap, MetadataValue } from '../shared/metadata.models'; +import { + MetadataMap, + MetadataValue, +} from '../shared/metadata.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; import { RestRequest } from './rest-request.model'; @@ -14,8 +18,8 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer // fallback for unexpected empty response const emptyPayload = { _embedded: { - objects: [] - } + objects: [], + }, }; const payload = data.payload._embedded.searchResult || emptyPayload; const hitHighlights: MetadataMap[] = payload._embedded.objects @@ -26,7 +30,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer for (const key of Object.keys(hhObject)) { const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), - language: null + language: null, }); mdMap[key] = [value]; } @@ -46,7 +50,7 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .map((object, index) => Object.assign({}, object, { indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], - _embedded: this.filterEmbeddedObjects(object) + _embedded: this.filterEmbeddedObjects(object), })); payload.objects = objects; const deserialized: any = new DSpaceSerializer(SearchObjects).deserialize(payload); @@ -65,8 +69,8 @@ export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingSer .reduce((obj, key) => { obj[key] = object._embedded.indexableObject._embedded[key]; return obj; - }, {}) - }) + }, {}), + }), }); } else { return object; diff --git a/src/app/core/data/notify-services-status-data.service.spec.ts b/src/app/core/data/notify-services-status-data.service.spec.ts new file mode 100644 index 0000000000..e336843505 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.spec.ts @@ -0,0 +1,88 @@ +import { + cold, + getTestScheduler, +} from 'jasmine-marbles'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotifyRequestsStatusDataService } from './notify-services-status-data.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; +import { RequestEntry } from './request-entry.model'; +import { RequestEntryState } from './request-entry-state.model'; + +describe('NotifyRequestsStatusDataService test', () => { + let scheduler: TestScheduler; + let service: NotifyRequestsStatusDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new NotifyRequestsStatusDataService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + 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: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + buildFromHref: createSuccessfulRemoteDataObject$({ test: 'test' }), + }); + + + service = initTestService(); + }); + + describe('getNotifyRequestsStatus', () => { + it('should get notify status', (done) => { + service.getNotifyRequestsStatus(requestUUID).subscribe((status) => { + expect(halService.getEndpoint).toHaveBeenCalled(); + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(status).toEqual(createSuccessfulRemoteDataObject({ test: 'test' } as unknown as NotifyRequestsStatus)); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/notify-services-status-data.service.ts b/src/app/core/data/notify-services-status-data.service.ts new file mode 100644 index 0000000000..ad0c32fadd --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { + map, + Observable, + take, +} from 'rxjs'; + +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; +import { NOTIFYREQUEST } from '../../item-page/simple/notify-requests-status/notify-requests-status.resource-type'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { dataService } from './base/data-service.decorator'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { RemoteData } from './remote-data'; +import { GetRequest } from './request.models'; +import { RequestService } from './request.service'; + +@Injectable() +@dataService(NOTIFYREQUEST) +export class NotifyRequestsStatusDataService extends IdentifiableDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('notifyrequests', requestService, rdbService, objectCache, halService); + } + + /** + * Retrieves the status of notify requests for a specific item. + * @param itemUuid The UUID of the item. + * @returns An Observable that emits the remote data containing the notify requests status. + */ + getNotifyRequestsStatus(itemUuid: string): Observable> { + const href$ = this.getEndpoint().pipe( + map((url: string) => url + '/' + itemUuid ), + ); + + href$.pipe(take(1)).subscribe((url: string) => { + const request = new GetRequest(this.requestService.generateRequestId(), url); + this.requestService.send(request, true); + }); + + return this.rdbService.buildFromHref(href$); + } +} diff --git a/src/app/core/data/object-updates/field-update.model.ts b/src/app/core/data/object-updates/field-update.model.ts index 47b6782471..71ae3bb68f 100644 --- a/src/app/core/data/object-updates/field-update.model.ts +++ b/src/app/core/data/object-updates/field-update.model.ts @@ -1,5 +1,5 @@ -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; /** * The state of a single field update diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 615dedbaf9..8a95a7827c 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,11 +1,12 @@ /* eslint-disable max-classes-per-file */ -import { type } from '../../../shared/ngrx/type'; import { Action } from '@ngrx/store'; + +import { type } from '../../../shared/ngrx/type'; import { INotification } from '../../../shared/notifications/models/notification.model'; -import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; import { FieldChangeType } from './field-change-type.model'; +import { Identifiable } from './identifiable.model'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * The list of ObjectUpdatesAction type definitions @@ -20,7 +21,7 @@ export const ObjectUpdatesActionTypes = { REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), - REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), }; @@ -49,7 +50,7 @@ export class InitializeFieldsAction implements Action { url: string, fields: Identifiable[], lastModified: Date, - patchOperationService?: GenericConstructor + patchOperationService?: GenericConstructor, ) { this.payload = { url, fields, lastModified, patchOperationService }; } @@ -113,7 +114,7 @@ export class SelectVirtualMetadataAction implements Action { uuid: string, select: boolean, ) { - this.payload = { url, source, uuid, select: select}; + this.payload = { url, source, uuid, select: select }; } } @@ -193,7 +194,7 @@ export class DiscardObjectUpdatesAction implements Action { constructor( url: string, notification: INotification, - discardAll = false + discardAll = false, ) { this.payload = { url, notification, discardAll }; } @@ -215,7 +216,7 @@ export class ReinstateObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be reinstated */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -237,7 +238,7 @@ export class RemoveObjectUpdatesAction implements Action { * the unique url of the page for which the changes should be removed */ constructor( - url: string + url: string, ) { this.payload = { url }; } @@ -269,7 +270,7 @@ export class RemoveFieldUpdateAction implements Action { */ constructor( url: string, - uuid: string + uuid: string, ) { this.payload = { url, uuid }; } diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts index ffd20a7300..10f37d78cb 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -1,24 +1,35 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Observable, Subject } from 'rxjs'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; +import { Action } from '@ngrx/store'; +import { + cold, + hot, +} from 'jasmine-marbles'; +import { + Observable, + Subject, +} from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { + INotification, + Notification, +} from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { ObjectUpdatesEffects } from './object-updates.effects'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction + RemoveObjectUpdatesAction, } from './object-updates.actions'; -import { - INotification, - Notification -} from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; -import { filter } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { ObjectUpdatesEffects } from './object-updates.effects'; describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; @@ -31,13 +42,7 @@ describe('ObjectUpdatesEffects', () => { providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), - { - provide: NotificationsService, - useValue: { - remove: (notification) => { /* empty */ - } - } - }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], }); })); @@ -59,7 +64,6 @@ describe('ObjectUpdatesEffects', () => { action = new RemoveObjectUpdatesAction(testURL); }); it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { - action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); const expected = cold('--b-', { b: undefined }); @@ -81,14 +85,19 @@ describe('ObjectUpdatesEffects', () => { removeAction = new RemoveObjectUpdatesAction(testURL); }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( - filter(((action) => hasValue(action)))) - .subscribe((t) => { - expect(t).toEqual(removeAction); - } - ) - ; + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('a', { a: undefined })); + + expect(emittedAction).toEqual(removeAction); }); }); @@ -98,12 +107,24 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return an action with type NO_ACTION', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { - expect(t).toEqual(new NoOpAction()); - } - ); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new ReinstateObjectUpdatesAction(testURL), + }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + take(2), + ).subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); @@ -113,12 +134,22 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new RemoveFieldUpdateAction(testURL, testUUID), + }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => - expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) - ); + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); }); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 1dfdc95f23..5ef86dbbec 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -1,24 +1,43 @@ import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { + of as observableOf, + race as observableRace, + Subject, +} from 'rxjs'; +import { + delay, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { + NotificationsActions, + NotificationsActionTypes, + RemoveNotificationAction, +} from '../../../shared/notifications/notifications.actions'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction, - RemoveObjectUpdatesAction + RemoveObjectUpdatesAction, } from './object-updates.actions'; -import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; -import { of as observableOf, race as observableRace, Subject } from 'rxjs'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { - NotificationsActions, - NotificationsActionTypes, - RemoveNotificationAction -} from '../../../shared/notifications/notifications.actions'; -import { Action } from '@ngrx/store'; -import { NoOpAction } from '../../../shared/ngrx/no-op.action'; /** * NGRX effects for ObjectUpdatesActions @@ -52,7 +71,7 @@ export class ObjectUpdatesEffects { /** * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key */ - mapLastActions$ = createEffect(() => this.actions$ + mapLastActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { @@ -63,23 +82,23 @@ export class ObjectUpdatesEffects { } this.actionMap$[url].next(action); } - }) + }), ), { dispatch: false }); /** * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key */ - mapLastNotificationActions$ = createEffect(() => this.actions$ + mapLastNotificationActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(NotificationsActionTypes)), map((action: RemoveNotificationAction) => { - const id: string = action.payload.id || action.payload || this.allIdentifier; - if (hasNoValue(this.notificationActionMap$[id])) { - this.notificationActionMap$[id] = new Subject(); - } - this.notificationActionMap$[id].next(action); + const id: string = action.payload.id || action.payload || this.allIdentifier; + if (hasNoValue(this.notificationActionMap$[id])) { + this.notificationActionMap$[id] = new Subject(); } - ) + this.notificationActionMap$[id].next(action); + }, + ), ), { dispatch: false }); /** @@ -88,52 +107,52 @@ export class ObjectUpdatesEffects { * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned */ - removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ + removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ .pipe( ofType(ObjectUpdatesActionTypes.DISCARD), switchMap((action: DiscardObjectUpdatesAction) => { - const url: string = action.payload.url; - const notification: INotification = action.payload.notification; - const timeOut = notification.options.timeOut; + const url: string = action.payload.url; + const notification: INotification = action.payload.notification; + const timeOut = notification.options.timeOut; - let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); - if (action.payload.discardAll) { - removeAction = new RemoveAllObjectUpdatesAction(); - } - - return observableRace( - // Either wait for the delay and perform a remove action - observableOf(removeAction).pipe(delay(timeOut)), - // Or wait for a a user action - this.actionMap$[url].pipe( - take(1), - tap(() => { - this.notificationsService.remove(notification); - }), - map((updateAction: ObjectUpdatesAction) => { - if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { - // If someone reinstated, do nothing, just let the reinstating happen - return new NoOpAction(); - } - // If someone performed another action, assume the user does not want to reinstate and remove all changes - return removeAction; - }) - ), - this.notificationActionMap$[notification.id].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), - map(() => { - return removeAction; - }) - ), - this.notificationActionMap$[this.allIdentifier].pipe( - filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), - map(() => { - return removeAction; - }) - ) - ); + let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); + if (action.payload.discardAll) { + removeAction = new RemoveAllObjectUpdatesAction(); } - ) + + return observableRace( + // Either wait for the delay and perform a remove action + observableOf(removeAction).pipe(delay(timeOut)), + // Or wait for a a user action + this.actionMap$[url].pipe( + take(1), + tap(() => { + this.notificationsService.remove(notification); + }), + map((updateAction: ObjectUpdatesAction) => { + if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { + // If someone reinstated, do nothing, just let the reinstating happen + return new NoOpAction(); + } + // If someone performed another action, assume the user does not want to reinstate and remove all changes + return removeAction; + }), + ), + this.notificationActionMap$[notification.id].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), + map(() => { + return removeAction; + }), + ), + this.notificationActionMap$[this.allIdentifier].pipe( + filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), + map(() => { + return removeAction; + }), + ), + ); + }, + ), )); constructor(private actions$: Actions, diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 08944a073f..1f2a15769b 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -1,5 +1,8 @@ // eslint-disable-next-line import/no-namespace import * as deepFreeze from 'deep-freeze'; + +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -10,11 +13,13 @@ import { RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { FieldChangeType } from './field-change-type.model'; +import { + OBJECT_UPDATES_TRASH_PATH, + objectUpdatesReducer, + ObjectUpdatesState, +} from './object-updates.reducer'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -29,26 +34,26 @@ const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, John' + value: 'Smith, John', }; const identifiable1update = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', key: 'dc.contributor.author', language: null, - value: 'Smith, James' + value: 'Smith, James', }; const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241', key: 'dc.title', language: null, - value: 'New title' + value: 'New title', }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e', key: 'dc.description.abstract', language: null, - value: 'Unchanged value' + value: 'Unchanged value', }; const relationship: Relationship = Object.assign(new Relationship(), { uuid: 'test relationship uuid' }); @@ -56,65 +61,62 @@ const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; const url = 'test-object.url/edit'; describe('objectUpdatesReducer', () => { - const testState = { + const testState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; - const discardedTestState = { + const discardedTestState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, }, [url + OBJECT_UPDATES_TRASH_PATH]: { @@ -122,35 +124,32 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: true, isNew: false, - isValid: true + isValid: true, }, [identifiable2.uuid]: { editable: false, isNew: true, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: false + isValid: false, }, }, fieldUpdates: { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }, lastModified: modDate, virtualMetadataSources: { - [relationship.uuid]: { [identifiable1.uuid]: true } + [relationship.uuid]: { [identifiable1.uuid]: true }, }, - } + }, }; deepFreeze(testState); @@ -173,48 +172,80 @@ describe('objectUpdatesReducer', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { const action = new SetEditableFieldUpdateAction(url, uuid, false); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_FIELD action without affecting the previous state', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the DISCARD action without affecting the previous state', () => { const action = new DiscardObjectUpdatesAction(url, null); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REINSTATE action without affecting the previous state', () => { const action = new ReinstateObjectUpdatesAction(url); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE_FIELD action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { @@ -226,19 +257,19 @@ describe('objectUpdatesReducer', () => { [identifiable1.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, [identifiable3.uuid]: { editable: false, isNew: false, - isValid: true + isValid: true, }, }, fieldUpdates: {}, virtualMetadataSources: {}, lastModified: modDate, - patchOperationService: undefined - } + patchOperationService: undefined, + }, }; const newState = objectUpdatesReducer(testState, action); expect(newState).toEqual(expectedState); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 14bacc52db..e014889850 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,3 +1,14 @@ +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { Item } from '../../shared/item.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -11,15 +22,7 @@ import { SetEditableFieldUpdateAction, SetValidFieldUpdateAction, } from './object-updates.actions'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { Item } from '../../shared/item.model'; -import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; /** * Path where discarded objects are saved @@ -77,7 +80,7 @@ export interface DeleteRelationship extends RelationshipIdentifiable { */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates; + fieldUpdates?: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; patchOperationService?: GenericConstructor; @@ -164,7 +167,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { { fieldUpdates: {} }, { virtualMetadataSources: {} }, { lastModified: lastModifiedServer }, - { patchOperationService } + { patchOperationService }, ); return Object.assign({}, state, { [url]: newPageState }); } @@ -178,7 +181,7 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { const url: string = action.payload.url; const field: Identifiable = action.payload.field; const changeType: FieldChangeType = action.payload.changeType; - const pageState: ObjectUpdatesEntry = state[url] || {fieldUpdates: {}}; + const pageState: ObjectUpdatesEntry = state[url] || { fieldUpdates: {} }; let states = pageState.fieldStates; if (changeType === FieldChangeType.ADD) { @@ -231,7 +234,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) const newPageState = Object.assign( {}, pageState, - {virtualMetadataSources: virtualMetadataSources}, + { virtualMetadataSources: virtualMetadataSources }, ); return Object.assign( @@ -239,7 +242,7 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) state, { [url]: newPageState, - } + }, ); } @@ -279,7 +282,7 @@ function discardObjectUpdatesFor(url: string, state: any) { const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -357,7 +360,7 @@ function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) { } newPageState = Object.assign({}, state[url], { fieldUpdates: newUpdates, - fieldStates: newFieldStates + fieldStates: newFieldStates, }); } return Object.assign({}, state, { [url]: newPageState }); diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 9cf856f03a..21486a581c 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -1,21 +1,22 @@ +import { Injector } from '@angular/core'; import { Store } from '@ngrx/store'; -import { ObjectUpdatesService } from './object-updates.service'; +import { of as observableOf } from 'rxjs'; + +import { Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { CoreState } from '../../core-state.model'; +import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; import { DiscardObjectUpdatesAction, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, - SetEditableFieldUpdateAction + SetEditableFieldUpdateAction, } from './object-updates.actions'; -import { of as observableOf } from 'rxjs'; -import { Notification } from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { Injector } from '@angular/core'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; +import { ObjectUpdatesService } from './object-updates.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -31,7 +32,7 @@ describe('ObjectUpdatesService', () => { const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; const modDate = new Date(2010, 2, 11); @@ -46,15 +47,15 @@ describe('ObjectUpdatesService', () => { }; patchOperationService = jasmine.createSpyObj('patchOperationService', { - fieldUpdatesToPatchOperations: [] + fieldUpdatesToPatchOperations: [], }); const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationService, }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); injector = jasmine.createSpyObj('injector', { - get: patchOperationService + get: patchOperationService, }); service = new ObjectUpdatesService(store, injector); @@ -80,7 +81,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, - [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD } + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, }; result$.subscribe((result) => { @@ -96,7 +97,7 @@ describe('ObjectUpdatesService', () => { const expectedResult = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, - [identifiable2.uuid]: { field: identifiable2, changeType: undefined } + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, }; result$.subscribe((result) => { diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 2fb6d47d31..b526e7e980 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,14 +1,36 @@ -import { Injectable, Injector } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { coreSelector } from '../../core.selectors'; import { - FieldState, - OBJECT_UPDATES_TRASH_PATH, - ObjectUpdatesEntry, - ObjectUpdatesState, - VirtualMetadataSource -} from './object-updates.reducer'; + Injectable, + Injector, +} from '@angular/core'; +import { + createSelector, + MemoizedSelector, + select, + Store, +} from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + switchMap, +} from 'rxjs/operators'; + +import { + hasNoValue, + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, +} from '../../../shared/empty.util'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { coreSelector } from '../../core.selectors'; +import { CoreState } from '../../core-state.model'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { FieldChangeType } from './field-change-type.model'; +import { FieldUpdates } from './field-updates.model'; +import { Identifiable } from './identifiable.model'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -17,24 +39,16 @@ import { RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, - SetValidFieldUpdateAction + SetValidFieldUpdateAction, } from './object-updates.actions'; -import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { - hasNoValue, - hasValue, - isEmpty, - isNotEmpty, - hasValueOperator -} from '../../../shared/empty.util'; -import { INotification } from '../../../shared/notifications/models/notification.model'; -import { Operation } from 'fast-json-patch'; + FieldState, + OBJECT_UPDATES_TRASH_PATH, + ObjectUpdatesEntry, + ObjectUpdatesState, + VirtualMetadataSource, +} from './object-updates.reducer'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { Identifiable } from './identifiable.model'; -import { FieldUpdates } from './field-updates.model'; -import { FieldChangeType } from './field-change-type.model'; -import { CoreState } from '../../core-state.model'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -122,7 +136,7 @@ export class ObjectUpdatesService { fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; }); return fieldUpdates; - }) + }), ); }), ); @@ -139,16 +153,16 @@ export class ObjectUpdatesService { return objectUpdates.pipe( hasValueOperator(), map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - for (const object of initialFields) { - let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; - if (isEmpty(fieldUpdate)) { - fieldUpdate = { field: object, changeType: undefined }; + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; } - fieldUpdates[object.uuid] = fieldUpdate; - } - return fieldUpdates; - })); + return fieldUpdates; + })); } /** @@ -161,7 +175,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.editable), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -175,7 +189,7 @@ export class ObjectUpdatesService { return fieldState$.pipe( filter((fieldState) => hasValue(fieldState)), map((fieldState) => fieldState.isValid), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -189,7 +203,7 @@ export class ObjectUpdatesService { map((entry: ObjectUpdatesEntry) => { return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0; }), - distinctUntilChanged() + distinctUntilChanged(), ); } @@ -234,7 +248,7 @@ export class ObjectUpdatesService { .pipe( select(virtualMetadataSourceSelector(url, relationship)), map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), - ); + ); } /** @@ -367,7 +381,7 @@ export class ObjectUpdatesService { patch = this.injector.get(entry.patchOperationService).fieldUpdatesToPatchOperations(entry.fieldUpdates); } return patch; - }) + }), ); } } diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts index db46426b79..ddf38dd2bb 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -1,8 +1,9 @@ -import { MetadataPatchOperationService } from './metadata-patch-operation.service'; import { Operation } from 'fast-json-patch'; + import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { FieldUpdates } from '../field-updates.model'; import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; +import { MetadataPatchOperationService } from './metadata-patch-operation.service'; describe('MetadataPatchOperationService', () => { let service: MetadataPatchOperationService; @@ -23,13 +24,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -46,13 +47,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Added title', - place: 0 + place: 0, }), - changeType: FieldChangeType.ADD - } + changeType: FieldChangeType.ADD, + }, }); expected = [ - { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] } + { op: 'add', path: '/metadata/dc.title/-', value: [{ value: 'Added title', language: undefined }] }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -69,13 +70,13 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Changed title', - place: 0 + place: 0, }), - changeType: FieldChangeType.UPDATE - } + changeType: FieldChangeType.UPDATE, + }, }); expected = [ - { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } } + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -92,31 +93,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/0' }, { op: 'remove', path: '/metadata/dc.title/0' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -133,31 +134,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/2' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -174,31 +175,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third deleted title', - place: 2 + place: 2, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'remove', path: '/metadata/dc.title/1' }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); @@ -215,31 +216,31 @@ describe('MetadataPatchOperationService', () => { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Second deleted title', - place: 1 + place: 1, }), - changeType: FieldChangeType.REMOVE + changeType: FieldChangeType.REMOVE, }, update2: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'Third changed title', - place: 2 + place: 2, }), - changeType: FieldChangeType.UPDATE + changeType: FieldChangeType.UPDATE, }, update3: { field: Object.assign(new MetadatumViewModel(), { key: 'dc.title', value: 'First deleted title', - place: 0 + place: 0, }), - changeType: FieldChangeType.REMOVE - } + changeType: FieldChangeType.REMOVE, + }, }); expected = [ { op: 'remove', path: '/metadata/dc.title/1' }, { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, - { op: 'remove', path: '/metadata/dc.title/0' } + { op: 'remove', path: '/metadata/dc.title/0' }, ] as any[]; result = service.fieldUpdatesToPatchOperations(fieldUpdates); }); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts index 33e9129a9d..b6dccb759b 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -1,21 +1,22 @@ -import { PatchOperationService } from './patch-operation.service'; -import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { Operation } from 'fast-json-patch'; import { Injectable } from '@angular/core'; -import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + import { hasValue } from '../../../../shared/empty.util'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldChangeType } from '../field-change-type.model'; +import { FieldUpdates } from '../field-updates.model'; import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; +import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; -import { FieldUpdates } from '../field-updates.model'; -import { FieldChangeType } from '../field-change-type.model'; +import { PatchOperationService } from './patch-operation.service'; /** * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values * This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class MetadataPatchOperationService implements PatchOperationService { @@ -75,7 +76,7 @@ export class MetadataPatchOperationService implements PatchOperationService { const metadatum = update.field as MetadatumViewModel; const val = { value: metadatum.value, - language: metadatum.language + language: metadatum.language, }; let operation: MetadataPatchOperation; diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts index 7f9b1d772f..9242290c6b 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch add Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts index 962d53dfee..d80ec16cd1 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch move Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts index 61fbae1980..efaf61f381 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch remove Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts index e889bede0b..c2d9581293 100644 --- a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts @@ -1,6 +1,7 @@ -import { MetadataPatchOperation } from './metadata-patch-operation.model'; import { Operation } from 'fast-json-patch'; +import { MetadataPatchOperation } from './metadata-patch-operation.model'; + /** * Wrapper object for a metadata patch replace Operation */ diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts index 171c1d2a54..7e9c1087ff 100644 --- a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -1,4 +1,5 @@ import { Operation } from 'fast-json-patch'; + import { FieldUpdates } from '../field-updates.model'; /** diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index 415bfe234e..e412af4986 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -1,13 +1,22 @@ -import { PageInfo } from '../shared/page-info.model'; -import { hasValue, isEmpty, hasNoValue, isUndefined } from '../../shared/empty.util'; -import { HALResource } from '../shared/hal-resource.model'; -import { HALLink } from '../shared/hal-link.model'; +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { + hasNoValue, + hasValue, + isEmpty, + isUndefined, +} from '../../shared/empty.util'; import { typedObject } from '../cache/builders/build-decorators'; -import { PAGINATED_LIST } from './paginated-list.resource-type'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { PageInfo } from '../shared/page-info.model'; import { ResourceType } from '../shared/resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; -import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../cache/cacheable-object.model'; +import { PAGINATED_LIST } from './paginated-list.resource-type'; /** * Factory function for a paginated list @@ -45,7 +54,7 @@ export const buildPaginatedList = (pageInfo: PageInfo, page: T[], normalized } result._links = Object.assign({}, _links, pageInfo._links, { - page: pageLinks + page: pageLinks, }); if (!normalized || isUndefined(pageLinks)) { @@ -64,13 +73,13 @@ export class PaginatedList extends CacheableObject { * The type of the list */ @excludeFromEquals - type = PAGINATED_LIST; + type = PAGINATED_LIST; /** * The type of objects in the list */ @autoserialize - objectType?: ResourceType; + objectType?: ResourceType; /** * The list of objects that represents the current page @@ -81,13 +90,13 @@ export class PaginatedList extends CacheableObject { * the {@link PageInfo} object */ @autoserialize - pageInfo?: PageInfo; + pageInfo?: PageInfo; /** * The {@link HALLink}s for this PaginatedList */ @deserialize - _links: { + _links: { self: HALLink; page: HALLink[]; first?: HALLink; diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index fbebe75b2b..9bf91121cc 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,5 +1,5 @@ -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ParsedResponse } from '../cache/response.models'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestRequest } from './rest-request.model'; export interface ResponseParsingService { diff --git a/src/app/core/data/primary-bitstream.service.spec.ts b/src/app/core/data/primary-bitstream.service.spec.ts index 00d6d7f03c..6a9c89f796 100644 --- a/src/app/core/data/primary-bitstream.service.spec.ts +++ b/src/app/core/data/primary-bitstream.service.spec.ts @@ -1,21 +1,31 @@ -import { ObjectCacheService } from '../cache/object-cache.service'; -import { RequestService } from './request.service'; -import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; -import { PrimaryBitstreamService } from './primary-bitstream.service'; -import { BundleDataService } from './bundle-data.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { CreateRequest, DeleteRequest, PostRequest, PutRequest } from './request.models'; -import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Bundle } from '../shared/bundle.model'; import { getTestScheduler } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BundleDataService } from './bundle-data.service'; +import { PrimaryBitstreamService } from './primary-bitstream.service'; +import { + CreateRequest, + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; + describe('PrimaryBitstreamService', () => { let service: PrimaryBitstreamService; let objectCache: ObjectCacheService; @@ -28,8 +38,8 @@ describe('PrimaryBitstreamService', () => { const bitstream = Object.assign(new Bitstream(), { uuid: 'fake-bitstream', _links: { - self: { href: 'fake-bitstream-self' } - } + self: { href: 'fake-bitstream-self' }, + }, }); const bundle = Object.assign(new Bundle(), { @@ -37,21 +47,21 @@ describe('PrimaryBitstreamService', () => { _links: { self: { href: 'fake-bundle-self' }, primaryBitstream: { href: 'fake-primary-bitstream-self' }, - } + }, }); const url = 'fake-bitstream-url'; beforeEach(() => { objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + remove: jasmine.createSpy('remove'), }); requestService = getMockRequestService(); halService = Object.assign(new HALEndpointServiceStub(url)); rdbService = getMockRemoteDataBuildService(); notificationService = new NotificationsServiceStub() as any; - bundleDataService = jasmine.createSpyObj('bundleDataService', {'findByHref': createSuccessfulRemoteDataObject$(bundle)}); + bundleDataService = jasmine.createSpyObj('bundleDataService', { 'findByHref': createSuccessfulRemoteDataObject$(bundle) }); service = new PrimaryBitstreamService(requestService, rdbService, objectCache, halService, notificationService, bundleDataService); }); @@ -96,7 +106,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PostRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -113,7 +123,7 @@ describe('PrimaryBitstreamService', () => { expect((service as any).createAndSendRequest).toHaveBeenCalledWith( PutRequest, bundle._links.primaryBitstream.href, - bitstream.self + bitstream.self, ); }); }); @@ -121,12 +131,12 @@ describe('PrimaryBitstreamService', () => { const testBundle = Object.assign(new Bundle(), { _links: { self: { - href: 'test-href' + href: 'test-href', }, primaryBitstream: { - href: 'test-primaryBitstream-href' - } - } + href: 'test-primaryBitstream-href', + }, + }, }); describe('when the delete request succeeds', () => { diff --git a/src/app/core/data/primary-bitstream.service.ts b/src/app/core/data/primary-bitstream.service.ts index 488cb5d22e..a5367e67ed 100644 --- a/src/app/core/data/primary-bitstream.service.ts +++ b/src/app/core/data/primary-bitstream.service.ts @@ -1,20 +1,28 @@ -import { Bitstream } from '../shared/bitstream.model'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; +import { + Observable, + switchMap, +} from 'rxjs'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable, switchMap } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { Bundle } from '../shared/bundle.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HttpHeaders } from '@angular/common/http'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { PutRequest, PostRequest, DeleteRequest } from './request.models'; -import { getAllCompletedRemoteData } from '../shared/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../shared/operators'; import { BundleDataService } from './bundle-data.service'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + PostRequest, + PutRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -63,8 +71,8 @@ export class PrimaryBitstreamService { requestId, endpointURL, primaryBitstreamSelfLink, - this.getHttpOptions() - ); + this.getHttpOptions(), + ); this.requestService.send(request); @@ -81,7 +89,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PostRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -95,7 +103,7 @@ export class PrimaryBitstreamService { return this.createAndSendRequest( PutRequest, bundle._links.primaryBitstream.href, - primaryBitstream.self + primaryBitstream.self, ) as Observable>; } @@ -107,12 +115,12 @@ export class PrimaryBitstreamService { delete(bundle: Bundle): Observable> { return this.createAndSendRequest( DeleteRequest, - bundle._links.primaryBitstream.href + bundle._links.primaryBitstream.href, ).pipe( getAllCompletedRemoteData(), switchMap((rd: RemoteData) => { return this.bundleDataService.findByHref(bundle.self, rd.hasFailed); - }) + }), ); } diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts index 88e5bd5791..217567776c 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -6,14 +6,197 @@ * http://www.dspace.org/license/ */ -import { testFindAllDataImplementation } from '../base/find-all-data.spec'; -import { ProcessDataService } from './process-data.service'; +import { + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { ReducerManager } from '@ngrx/store'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessStatus } from '../../../process-page/processes/process-status.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { testDeleteDataImplementation } from '../base/delete-data.spec'; +import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { BitstreamFormatDataService } from '../bitstream-format-data.service'; +import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; +import { RequestEntryState } from '../request-entry-state.model'; +import { + ProcessDataService, + TIMER_FACTORY, +} from './process-data.service'; describe('ProcessDataService', () => { + let testScheduler; + + const mockTimer = (fn: () => any, interval: number) => { + fn(); + return 555; + }; + describe('composition', () => { - const initService = () => new ProcessDataService(null, null, null, null, null, null); + const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); + }); + + let requestService = getMockRequestService(); + let processDataService; + let remoteDataBuildService; + + describe('autoRefreshUntilCompletion', () => { + beforeEach(waitForAsync(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: null }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + spyOn(processDataService, 'invalidateByHref'); + })); + + it('should not do any polling when the process is already completed', () => { + testScheduler.run(({ cold, expectObservable }) => { + let completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('c', { + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('instantly'); + expectObservable(process$).toBe('c', { + c: completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).not.toHaveBeenCalled(); + }); + + it('should poll until a process completes', () => { + testScheduler.run(({ cold, expectObservable }) => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + const completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess); + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }), + ); + + let process$ = processDataService.autoRefreshUntilCompletion('foo', 100); + expectObservable(process$).toBe('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD, + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoRefreshingSearchBy', () => { + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ], + }); + + processDataService = TestBed.inject(ProcessDataService); + })); + + it('should refresh after the specified interval', fakeAsync(() => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123', + }, + }, + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + + const runningProcessPagination: PaginatedList = Object.assign(new PaginatedList(), { + page: [runningProcess], + _links: { + self: { + href: 'https://rest.api/processesList/456', + }, + }, + }); + + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination); + + spyOn(processDataService, 'searchBy').and.returnValue( + of(runningProcessRD), + ); + + expect(processDataService.searchBy).toHaveBeenCalledTimes(0); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0); + + let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe(); + expect(processDataService.searchBy).toHaveBeenCalledTimes(1); + + tick(250); + + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1); + + sub.unsubscribe(); + })); }); }); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 3bf34eb650..ae0dfc11fa 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,30 +1,70 @@ -import { Injectable } from '@angular/core'; -import { RequestService } from '../request.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { + Inject, + Injectable, + InjectionToken, + NgZone, +} from '@angular/core'; +import { + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + switchMap, +} from 'rxjs/operators'; +import { ProcessStatus } from 'src/app/process-page/processes/process-status.model'; + import { Process } from '../../../process-page/processes/process.model'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; -import { PaginatedList } from '../paginated-list.model'; -import { Bitstream } from '../../shared/bitstream.model'; -import { RemoteData } from '../remote-data'; -import { BitstreamDataService } from '../bitstream-data.service'; -import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { dataService } from '../base/data-service.decorator'; -import { DeleteData, DeleteDataImpl } from '../base/delete-data'; +import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Bitstream } from '../../shared/bitstream.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NoContent } from '../../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../../shared/operators'; +import { dataService } from '../base/data-service.decorator'; +import { + DeleteData, + DeleteDataImpl, +} from '../base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { + SearchData, + SearchDataImpl, +} from '../base/search-data'; +import { BitstreamDataService } from '../bitstream-data.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { RemoteData } from '../remote-data'; +import { RequestService } from '../request.service'; + +/** + * Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during + * testing. (fakeAsync isn't working for this case) + */ +export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', { + providedIn: 'root', + factory: () => setTimeout, +}); @Injectable() @dataService(PROCESS) -export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData, SearchData { + private findAllData: FindAllData; private deleteData: DeleteData; + private searchData: SearchData; + protected activelyBeingPolled: Map = new Map(); + protected subs: Map = new Map(); constructor( protected requestService: RequestService, @@ -33,11 +73,30 @@ export class ProcessDataService extends IdentifiableDataService impleme protected halService: HALEndpointService, protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, + protected zone: NgZone, + @Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout, ) { super('processes', requestService, rdbService, objectCache, halService); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return true if the given process has the given status + * @protected + */ + protected static statusIs(process: Process, status: ProcessStatus): boolean { + return hasValue(process) && process.processStatus === status; + } + + /** + * Return true if the given process has the status COMPLETED or FAILED + */ + public static hasCompletedOrFailed(process: Process): boolean { + return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) || + ProcessDataService.statusIs(process, ProcessStatus.FAILED); } /** @@ -46,7 +105,7 @@ export class ProcessDataService extends IdentifiableDataService impleme */ getFilesEndpoint(processId: string): Observable { return this.getBrowseEndpoint().pipe( - switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)) + switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)), ); } @@ -77,6 +136,71 @@ export class ProcessDataService extends IdentifiableDataService impleme return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * @param searchMethod The search method for the Process + * @param options The FindListOptions 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 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 automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * @param id The id for this auto-refreshing search. Used to stop + * auto-refreshing afterwards, and ensure we're not + * auto-refreshing the same thing multiple times. + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param pollingIntervalInMs The interval by which the search will be repeated + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes every interval + */ + autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + + const sub = result$.pipe( + filter(() => + !this.activelyBeingPolled.has(id), + ), + ).subscribe((processListRd: RemoteData>) => { + this.clearCurrentTimeout(id); + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(id); + this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(id, nextTimeout); + }); + + this.subs.set(id, sub); + + return result$; + } + + /** + * Stop auto-refreshing the request with the given id + * @param id the id of the request to stop automatically refreshing + */ + stopAutoRefreshing(id: string) { + this.clearCurrentTimeout(id); + if (hasValue(this.subs.get(id))) { + this.subs.get(id).unsubscribe(); + this.subs.delete(id); + } + } + /** * Delete an existing object on the server * @param objectId The id of the object to be removed @@ -101,4 +225,74 @@ export class ProcessDataService extends IdentifiableDataService impleme public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Clear the timeout for the given id, if that timeout exists + * @protected + */ + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); + if (hasValue(timeout)) { + clearTimeout(timeout); + } + this.activelyBeingPolled.delete(id); + } + + /** + * Poll the process with the given ID, using the given interval, until that process either + * completes successfully or fails + * + * Return an Observable for the Process. Note that this will also emit while the + * process is still running. It will only emit again when the process (not the RemoteData!) changes + * status. That makes it more convenient to retrieve that process for a component: you can replace + * a findByID call with this method, rather than having to do a separate findById, and then call + * this method + * + * @param processId The ID of the {@link Process} to poll + * @param pollingIntervalInMs The interval for how often the request needs to be polled + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be + * automatically resolved + */ + public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig[]): Observable> { + const process$: Observable> = this.findById(processId, true, true, ...linksToFollow) + .pipe( + getAllCompletedRemoteData(), + ); + + // Create a subscription that marks the data as stale if the process hasn't been completed and + // the polling interval time has been exceeded. + const sub = process$.pipe( + filter((processRD: RemoteData) => + !ProcessDataService.hasCompletedOrFailed(processRD.payload) && + !this.activelyBeingPolled.has(processId), + ), + ).subscribe((processRD: RemoteData) => { + this.clearCurrentTimeout(processId); + if (processRD.hasSucceeded) { + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(processId); + this.invalidateByHref(processRD.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(processId, nextTimeout); + } + }); + + this.subs.set(processId, sub); + + // When the process completes create a one off subscription (the `find` completes the + // observable) that unsubscribes the previous one, removes the processId from the list of + // processes being polled and clears any running timeouts + process$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)), + ).subscribe(() => { + this.stopAutoRefreshing(processId); + }); + + return process$.pipe( + distinctUntilChanged((previous: RemoteData, current: RemoteData) => + previous.payload?.processStatus === current.payload?.processStatus, + ), + ); + } } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index d9c92cb1d2..901a92e818 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,26 +1,33 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + map, + take, +} from 'rxjs/operators'; + +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; +import { Script } from '../../../process-page/scripts/script.model'; +import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; +import { hasValue } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { Script } from '../../../process-page/scripts/script.model'; -import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; -import { map, take } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { dataService } from '../base/data-service.decorator'; +import { + FindAllData, + FindAllDataImpl, +} from '../base/find-all-data'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; import { RemoteData } from '../remote-data'; import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; -import { Observable } from 'rxjs'; -import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; -import { Process } from '../../../process-page/processes/process.model'; -import { hasValue } from '../../../shared/empty.util'; -import { getFirstCompletedRemoteData } from '../../shared/operators'; import { RestRequest } from '../rest-request.model'; -import { IdentifiableDataService } from '../base/identifiable-data.service'; -import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; -import { FindListOptions } from '../find-list-options.model'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../paginated-list.model'; -import { dataService } from '../base/data-service.decorator'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; @@ -51,7 +58,7 @@ export class ScriptDataService extends IdentifiableDataService
{{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..c63ff40df8 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -1,35 +1,68 @@ import { CommonModule } from '@angular/common'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; 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 } from 'rxjs'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { RestResponse } from '../../../../core/cache/response.models'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + 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 { PaginationService } from '../../../../core/pagination/pagination.service'; 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 { MembersListComponent } from './members-list.component'; -import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; -import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; -import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; -import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { RouterMock } from '../../../../shared/mocks/router.mock'; -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 { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../../shared/mocks/router.mock'; +import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { + EPersonMock, + EPersonMock2, +} from '../../../../shared/testing/eperson.mock'; +import { GroupMock } from '../../../../shared/testing/group-mock'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { MembersListComponent } from './members-list.component'; + +// todo: optimize imports describe('MembersListComponent', () => { let component: MembersListComponent; @@ -39,28 +72,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 +101,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,16 +133,16 @@ 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')); - } + }, }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); @@ -125,8 +153,8 @@ describe('MembersListComponent', () => { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], declarations: [MembersListComponent], @@ -139,7 +167,7 @@ describe('MembersListComponent', () => { { provide: PaginationService, useValue: paginationService }, { provide: DSONameService, useValue: new DSONameServiceMock() }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -160,13 +188,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 +226,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..002e20524c 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 @@ -1,41 +1,49 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +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 { - Observable, - of as observableOf, - Subscription, BehaviorSubject, - combineLatest as observableCombineLatest, - ObservedValueOf, + Observable, + Subscription, } 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 { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +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 { PaginationService } from '../../../../core/pagination/pagination.service'; import { - getFirstSucceededRemoteData, - getFirstCompletedRemoteData, getAllCompletedRemoteData, - getRemoteDataPayload + getFirstCompletedRemoteData, + 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'; + +// todo: optimize imports /** * Keys to keep track of specific subscriptions */ enum SubKey { ActiveGroup, - MembersDTO, - SearchResultsDTO, + Members, + SearchResults, } /** @@ -69,7 +77,7 @@ export interface EPersonListActionConfig { @Component({ selector: 'ds-members-list', - templateUrl: './members-list.component.html' + templateUrl: './members-list.component.html', }) /** * The list of members in the edit group page @@ -77,30 +85,30 @@ export interface EPersonListActionConfig { export class MembersListComponent implements OnInit, OnDestroy { @Input() - messagePrefix: string; + messagePrefix: string; @Input() - actionConfig: EPersonListActionConfig = { - add: { - css: 'btn-outline-primary', - disabled: false, - icon: 'fas fa-plus fa-fw', - }, - remove: { - css: 'btn-outline-danger', - disabled: false, - icon: 'fas fa-trash-alt fa-fw' - }, - }; + actionConfig: EPersonListActionConfig = { + add: { + css: 'btn-outline-primary', + disabled: false, + icon: 'fas fa-plus fa-fw', + }, + remove: { + css: 'btn-outline-danger', + disabled: false, + icon: 'fas fa-trash-alt fa-fw', + }, + }; /** * 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 @@ -108,7 +116,7 @@ export class MembersListComponent implements OnInit, OnDestroy { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'sml', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** * Pagination config used to display the list of EPerson Membes of active group being edited @@ -116,7 +124,7 @@ export class MembersListComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'ml', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** @@ -129,7 +137,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 +144,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 +157,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,14 +179,14 @@ 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, { - currentPage: currentPagination.currentPage, - elementsPerPage: currentPagination.pageSize - } + currentPage: currentPagination.currentPage, + elementsPerPage: currentPagination.pageSize, + }, ); }), getAllCompletedRemoteData(), @@ -189,49 +197,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 +219,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 +239,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 +258,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 - }); + elementsPerPage: paginationOptions.pageSize, + }, false, true); }), getAllCompletedRemoteData(), map((rd: RemoteData) => { @@ -319,23 +286,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..ab7bd84f6b 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..ca50406aea 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,36 +1,63 @@ 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BrowserModule, By } from '@angular/platform-browser'; +import { + DebugElement, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +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 { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { RestResponse } from '../../../../core/cache/response.models'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { + buildPaginatedList, + 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 { PaginationService } from '../../../../core/pagination/pagination.service'; 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 { SubgroupsListComponent } from './subgroups-list.component'; -import { - 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 { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RouterMock } from '../../../../shared/mocks/router.mock'; +import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { + GroupMock, + GroupMock2, +} from '../../../../shared/testing/group-mock'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { SubgroupsListComponent } from './subgroups-list.component'; describe('SubgroupsListComponent', () => { let component: SubgroupsListComponent; @@ -39,44 +66,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,27 +138,30 @@ 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')); - } + }, }; routerStub = new RouterMock(); builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], declarations: [SubgroupsListComponent], @@ -117,7 +173,7 @@ describe('SubgroupsListComponent', () => { { provide: Router, useValue: routerStub }, { provide: PaginationService, useValue: paginationService }, ], - schemas: [NO_ERRORS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); })); @@ -137,30 +193,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 +233,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..ae677a5558 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 @@ -1,24 +1,38 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +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 { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; 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 { PaginationService } from '../../../../core/pagination/pagination.service'; +import { NoContent } from '../../../../core/shared/NoContent.model'; import { + getAllCompletedRemoteData, getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getRemoteDataPayload } from '../../../../core/shared/operators'; +import { PageInfo } from '../../../../core/shared/page-info.model'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { NoContent } from '../../../../core/shared/NoContent.model'; -import { PaginationService } from '../../../../core/pagination/pagination.service'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; /** * Keys to keep track of specific subscriptions @@ -31,7 +45,7 @@ enum SubKey { @Component({ selector: 'ds-subgroups-list', - templateUrl: './subgroups-list.component.html' + templateUrl: './subgroups-list.component.html', }) /** * The list of subgroups in the edit group page @@ -39,7 +53,7 @@ enum SubKey { export class SubgroupsListComponent implements OnInit, OnDestroy { @Input() - messagePrefix: string; + messagePrefix: string; /** * Result of search groups, initially all groups @@ -50,6 +64,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { */ subGroups$: BehaviorSubject>> = new BehaviorSubject(undefined); + subGroupsPageInfoState$: Observable; + /** * Map of active subscriptions */ @@ -61,7 +77,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'ssgl', pageSize: 5, - currentPage: 1 + currentPage: 1, }); /** * Pagination config used to display the list of subgroups of currently active group being edited @@ -69,7 +85,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'sgl', pageSize: 5, - currentPage: 1 + currentPage: 1, }); // The search form @@ -103,8 +119,12 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup != null) { this.groupBeingEdited = activeGroup; this.retrieveSubGroups(); + this.search({ query: '' }); } })); + this.subGroupsPageInfoState$ = this.subGroups$.pipe( + map(subGroupsRD => subGroupsRD?.payload?.pageInfo), + ); } /** @@ -119,59 +139,18 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { - currentPage: config.currentPage, - elementsPerPage: config.pageSize - }, - true, - true, - followLink('object') - )) + currentPage: config.currentPage, + elementsPerPage: config.pageSize, + }, + true, + true, + followLink('object'), + )), ).subscribe((rd: RemoteData>) => { this.subGroups$.next(rd); })); } - /** - * 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 +160,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 +181,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 +196,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/group-form/validators/group-exists.validator.ts b/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts index 88f22413e9..056c54bf6a 100644 --- a/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts +++ b/src/app/access-control/group-registry/group-form/validators/group-exists.validator.ts @@ -1,10 +1,13 @@ -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; import { Observable } from 'rxjs'; -import { map} from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; import { Group } from '../../../../core/eperson/models/group.model'; +import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; export class ValidateGroupExists { @@ -16,9 +19,9 @@ export class ValidateGroupExists { static createValidator(groupDataService: GroupDataService) { return (control: AbstractControl): Promise | Observable => { return groupDataService.searchGroups(control.value, { - currentPage: 1, - elementsPerPage: 100 - }) + currentPage: 1, + elementsPerPage: 100, + }) .pipe( getFirstSucceededRemoteListPayload(), map( (groups: Group[]) => { diff --git a/src/app/access-control/group-registry/group-page.guard.spec.ts b/src/app/access-control/group-registry/group-page.guard.spec.ts index 48fa124c07..b1648f59ec 100644 --- a/src/app/access-control/group-registry/group-page.guard.spec.ts +++ b/src/app/access-control/group-registry/group-page.guard.spec.ts @@ -1,10 +1,14 @@ -import { GroupPageGuard } from './group-page.guard'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, +} from '@angular/router'; import { of as observableOf } from 'rxjs'; + import { AuthService } from '../../core/auth/auth.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { GroupPageGuard } from './group-page.guard'; describe('GroupPageGuard', () => { const groupsEndpointUrl = 'https://test.org/api/eperson/groups'; @@ -13,7 +17,7 @@ describe('GroupPageGuard', () => { const routeSnapshotWithGroupId = { params: { groupId: groupUuid, - } + }, } as unknown as ActivatedRouteSnapshot; let guard: GroupPageGuard; @@ -50,10 +54,10 @@ describe('GroupPageGuard', () => { it('should return true', (done) => { guard.canActivate( - routeSnapshotWithGroupId, { url: 'current-url'} as any + routeSnapshotWithGroupId, { url: 'current-url' } as any, ).subscribe((result) => { expect(authorizationService.isAuthorized).toHaveBeenCalledWith( - FeatureID.CanManageGroup, groupEndpointUrl, undefined + FeatureID.CanManageGroup, groupEndpointUrl, undefined, ); expect(result).toBeTrue(); done(); @@ -68,10 +72,10 @@ describe('GroupPageGuard', () => { it('should not return true', (done) => { guard.canActivate( - routeSnapshotWithGroupId, { url: 'current-url'} as any + routeSnapshotWithGroupId, { url: 'current-url' } as any, ).subscribe((result) => { expect(authorizationService.isAuthorized).toHaveBeenCalledWith( - FeatureID.CanManageGroup, groupEndpointUrl, undefined + FeatureID.CanManageGroup, groupEndpointUrl, undefined, ); expect(result).not.toBeTrue(); done(); diff --git a/src/app/access-control/group-registry/group-page.guard.ts b/src/app/access-control/group-registry/group-page.guard.ts index 057f67ddeb..928271887c 100644 --- a/src/app/access-control/group-registry/group-page.guard.ts +++ b/src/app/access-control/group-registry/group-page.guard.ts @@ -1,15 +1,23 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable, of as observableOf } from 'rxjs'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; -import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { map } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; + @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class GroupPageGuard extends SomeFeatureAuthorizationGuard { @@ -28,7 +36,7 @@ export class GroupPageGuard extends SomeFeatureAuthorizationGuard { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe( - map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`) + map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`), ); } diff --git a/src/app/access-control/group-registry/group-registry.actions.ts b/src/app/access-control/group-registry/group-registry.actions.ts index 8144bd0599..d1bc62a95c 100644 --- a/src/app/access-control/group-registry/group-registry.actions.ts +++ b/src/app/access-control/group-registry/group-registry.actions.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; + import { Group } from '../../core/eperson/models/group.model'; import { type } from '../../shared/ngrx/type'; diff --git a/src/app/access-control/group-registry/group-registry.reducers.spec.ts b/src/app/access-control/group-registry/group-registry.reducers.spec.ts index de5b65f5ba..83a6df580d 100644 --- a/src/app/access-control/group-registry/group-registry.reducers.spec.ts +++ b/src/app/access-control/group-registry/group-registry.reducers.spec.ts @@ -1,6 +1,12 @@ import { GroupMock } from '../../shared/testing/group-mock'; -import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction } from './group-registry.actions'; -import { groupRegistryReducer, GroupRegistryState } from './group-registry.reducers'; +import { + GroupRegistryCancelGroupAction, + GroupRegistryEditGroupAction, +} from './group-registry.actions'; +import { + groupRegistryReducer, + GroupRegistryState, +} from './group-registry.reducers'; const initialState: GroupRegistryState = { editGroup: null, diff --git a/src/app/access-control/group-registry/group-registry.reducers.ts b/src/app/access-control/group-registry/group-registry.reducers.ts index 8e288b7f3a..0bb3ad4b5c 100644 --- a/src/app/access-control/group-registry/group-registry.reducers.ts +++ b/src/app/access-control/group-registry/group-registry.reducers.ts @@ -1,5 +1,9 @@ import { Group } from '../../core/eperson/models/group.model'; -import { GroupRegistryAction, GroupRegistryActionTypes, GroupRegistryEditGroupAction } from './group-registry.actions'; +import { + GroupRegistryAction, + GroupRegistryActionTypes, + GroupRegistryEditGroupAction, +} from './group-registry.actions'; /** * The metadata registry state. @@ -27,13 +31,13 @@ export function groupRegistryReducer(state = initialState, action: GroupRegistry case GroupRegistryActionTypes.EDIT_GROUP: { return Object.assign({}, state, { - editGroup: (action as GroupRegistryEditGroupAction).group + editGroup: (action as GroupRegistryEditGroupAction).group, }); } case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: { return Object.assign({}, state, { - editGroup: null + editGroup: null, }); } 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..2ef67ddf54 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}}

- +
@@ -35,7 +35,7 @@ -