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/codescan.yml b/.github/workflows/codescan.yml index 35a2e2d24a..8b415296c7 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' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9a2c838d83..04112f7b70 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,106 +15,288 @@ on: permissions: contents: read # to fetch code (actions/checkout) + +env: + REGISTRY_IMAGE: dspace/dspace-angular + # 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 'latest' 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=latest,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 manage the 'latest' tag ourselves to the 'main' branch (see settings above) + TAGS_FLAVOR: | + latest=false + jobs: - docker: + ############################################################# + # Build/Push the '${{ env.REGISTRY_IMAGE }}' 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' || '' }} + strategy: + matrix: + # Architectures / Platforms for which we will build Docker images + arch: ['linux/amd64', 'linux/arm64'] + os: [ubuntu-latest] + isPr: + - ${{ github.event_name == 'pull_request' }} + # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work. + # The below exclude therefore ensures we do NOT build ARM64 for PRs. + exclude: + - isPr: true + os: ubuntu-latest + arch: linux/arm64 + + runs-on: ${{ matrix.os }} steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v3 + uses: actions/checkout@v4 # https://github.com/docker/setup-buildx-action - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 # https://github.com/docker/setup-qemu-action - name: Set up QEMU emulation to build for multiple architectures - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 # 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 + if: ${{ ! matrix.isPr }} + uses: docker/login-action@v3 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 + uses: docker/metadata-action@v5 with: - images: dspace/dspace-angular + images: ${{ env.REGISTRY_IMAGE }} 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 + uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile - platforms: ${{ env.PLATFORMS }} + platforms: ${{ matrix.arch }} # 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' }} + push: ${{ ! matrix.isPr }} # 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) - ##################################################### + # Export the digest of Docker build locally (for non PRs only) + - name: Export digest + if: ${{ ! matrix.isPr }} + run: | + mkdir -p /tmp/digests + digest="${{ steps.docker_build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + # Upload digest to an artifact, so that it can be used in manifest below + - name: Upload digest + if: ${{ ! matrix.isPr }} + uses: actions/upload-artifact@v3 + with: + name: digests + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Merge digests into a manifest. + # This runs after all Docker builds complete above, and it tells hub.docker.com + # that these builds should be all included in the manifest for this tag. + # (e.g. AMD64 and ARM64 should be listed as options under the same tagged Docker image) + # Borrowed from https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners + dspace-angular_manifest: + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + needs: + - dspace-angular + steps: + - name: Download digests + uses: actions/download-artifact@v3 + with: + name: digests + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Add Docker metadata for image + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: ${{ env.IMAGE_TAGS }} + flavor: ${{ env.TAGS_FLAVOR }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Create manifest list from digests and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} + + ############################################################# + # Build/Push the '${{ env.REGISTRY_IMAGE }}' 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' + + strategy: + matrix: + # Architectures / Platforms for which we will build Docker images + arch: ['linux/amd64', 'linux/arm64'] + os: [ubuntu-latest] + isPr: + - ${{ github.event_name == 'pull_request' }} + # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work. + # The below exclude therefore ensures we do NOT build ARM64 for PRs. + exclude: + - isPr: true + os: ubuntu-latest + arch: linux/arm64 + + runs-on: ${{ matrix.os }} + steps: + # https://github.com/actions/checkout + - name: Checkout codebase + uses: actions/checkout@v4 + + # https://github.com/docker/setup-buildx-action + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU emulation to build for multiple architectures + uses: docker/setup-qemu-action@v3 + + # 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: ${{ ! matrix.isPr }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + # 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 + uses: docker/metadata-action@v5 with: - images: dspace/dspace-angular + images: ${{ env.REGISTRY_IMAGE }} 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. + # tagging logic as the primary '${{ env.REGISTRY_IMAGE }}' 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 + uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile.dist - platforms: ${{ env.PLATFORMS }} + platforms: ${{ matrix.arch }} # 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' }} + push: ${{ ! matrix.isPr }} # Use tags / labels provided by 'docker/metadata-action' above tags: ${{ steps.meta_build_dist.outputs.tags }} labels: ${{ steps.meta_build_dist.outputs.labels }} + + # Export the digest of Docker build locally (for non PRs only) + - name: Export digest + if: ${{ ! matrix.isPr }} + run: | + mkdir -p /tmp/digests + digest="${{ steps.docker_build_dist.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + # Upload Digest to an artifact, so that it can be used in manifest below + - name: Upload digest + if: ${{ ! matrix.isPr }} + uses: actions/upload-artifact@v3 + with: + # NOTE: It's important that this artifact has a unique name so that two + # image builds don't upload digests to the same artifact. + name: digests-dist + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Merge *-dist digests into a manifest. + # This runs after all Docker builds complete above, and it tells hub.docker.com + # that these builds should be all included in the manifest for this tag. + # (e.g. AMD64 and ARM64 should be listed as options under the same tagged Docker image) + dspace-angular-dist_manifest: + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + needs: + - dspace-angular-dist + steps: + - name: Download digests for -dist builds + uses: actions/download-artifact@v3 + with: + name: digests-dist + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Add Docker metadata for image + id: meta_dist + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + 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 '${{ env.REGISTRY_IMAGE }}' image above. + flavor: ${{ env.TAGS_FLAVOR }} + suffix=-dist + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Create manifest list from digests and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta_dist.outputs.version }} 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..109835d14d --- /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@v3 + # 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@v1 + 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..9b61af72d1 --- /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@v1.6.2 diff --git a/.gitignore b/.gitignore index bdd0d4e589..7d065aca06 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ package-lock.json /nbproject/ junit.xml + +/src/mirador-viewer/config.local.js 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 c90dc1d08f..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: ``` @@ -413,8 +413,7 @@ dspace-angular │ ├── merge-i18n-files.ts * │ ├── serve.ts * │ ├── sync-i18n-files.ts * -│ ├── test-rest.ts * -│ └── webpack.js * +│ └── test-rest.ts * ├── src * The source of the application │ ├── app * The source code of the application, subdivided by module/page. │ ├── assets * Folder for static resources diff --git a/config/config.example.yml b/config/config.example.yml index ea38303fa3..69a9ffd320 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -22,7 +22,7 @@ ui: # 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. rest: ssl: true - host: api7.dspace.org + host: sandbox.dspace.org port: 443 # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: /server @@ -208,6 +208,9 @@ languages: - code: pt-BR label: Português do Brasil active: true + - code: sr-lat + label: Srpski (lat) + active: true - code: fi label: Suomi active: true @@ -232,6 +235,9 @@ languages: - code: el label: Ελληνικά active: true + - code: sr-cyr + label: Српски + active: true - code: uk label: Yкраї́нська active: true @@ -292,33 +298,33 @@ themes: # # # A theme with a handle property will match the community, collection or item with the given # # handle, and all collections and/or items within it - # - name: 'custom', - # handle: '10673/1233' + # - name: custom + # handle: 10673/1233 # # # A theme with a regex property will match the route using a regular expression. If it # # matches the route for a community or collection it will also apply to all collections # # and/or items within it - # - name: 'custom', - # regex: 'collections\/e8043bc2.*' + # - name: custom + # regex: collections\/e8043bc2.* # # # A theme with a uuid property will match the community, collection or item with the given # # ID, and all collections and/or items within it - # - name: 'custom', - # uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + # - name: custom + # uuid: 0958c910-2037-42a9-81c7-dca80e3892b4 # # # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found # # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default. - # - name: 'custom-A', - # extends: 'custom-B', + # - name: custom-A + # extends: custom-B # # Any of the matching properties above can be used - # handle: '10673/34' + # handle: 10673/34 # - # - name: 'custom-B', - # extends: 'custom', - # handle: '10673/12' + # - name: custom-B + # extends: custom + # handle: 10673/12 # # # A theme with only a name will match every route - # name: 'custom' + # name: custom # # # This theme will use the default bootstrap styling for DSpace components # - name: BASE_THEME_NAME @@ -379,4 +385,4 @@ vocabularies: # Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' - sortDirection: 'ASC' \ No newline at end of file + sortDirection: 'ASC' diff --git a/config/config.yml b/config/config.yml index b5eecd112f..109db60ca9 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,5 +1,5 @@ rest: ssl: true - host: api7.dspace.org + host: sandbox.dspace.org port: 443 nameSpace: /server diff --git a/cypress/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/header.cy.ts b/cypress/e2e/header.cy.ts index 236208db68..1a9b841eb7 100644 --- a/cypress/e2e/header.cy.ts +++ b/cypress/e2e/header.cy.ts @@ -11,8 +11,7 @@ describe('Header', () => { 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 + ['#search-navbar-container'] // search in navbar has duplicative ID. Will be fixed in #1174 ], }); }); diff --git a/cypress/e2e/homepage.cy.ts b/cypress/e2e/homepage.cy.ts index 8fdf61dbf7..a387c31a2a 100644 --- a/cypress/e2e/homepage.cy.ts +++ b/cypress/e2e/homepage.cy.ts @@ -6,8 +6,8 @@ describe('Homepage', () => { cy.visit('/'); }); - it('should display translated title "DSpace Angular :: Home"', () => { - cy.title().should('eq', 'DSpace Angular :: Home'); + it('should display translated title "DSpace Repository :: Home"', () => { + cy.title().should('eq', 'DSpace Repository :: Home'); }); it('should contain a news section', () => { diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index 9eed711776..9dba6eb8ce 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,4 +1,3 @@ -import { Options } from 'cypress-axe'; import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; @@ -19,13 +18,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/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index b169634cfa..d29c13c2f9 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,4 +1,5 @@ import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; const page = { openLoginMenu() { @@ -123,4 +124,15 @@ describe('Login Modal', () => { cy.location('pathname').should('eq', '/forgot'); cy.get('ds-forgot-email').should('exist'); }); + + it('should pass accessibility tests', () => { + cy.visit('/'); + + page.openLoginMenu(); + + cy.get('ds-log-in').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-log-in'); + }); }); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 79786c298a..13f4a1b547 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -19,21 +19,7 @@ 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', () => { 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-page.cy.ts b/cypress/e2e/search-page.cy.ts index 24519cc236..755f8eaac6 100644 --- a/cypress/e2e/search-page.cy.ts +++ b/cypress/e2e/search-page.cy.ts @@ -27,21 +27,7 @@ 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', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c70c4e37e1..92f0b1aeeb 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -177,6 +177,8 @@ function generateViewEvent(uuid: string, dsoType: string): void { [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 }, diff --git a/docker/README.md b/docker/README.md index 42deb793f9..d0cee3f52a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -23,14 +23,14 @@ the Docker compose scripts in this 'docker' folder. This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' ``` -docker build -t dspace/dspace-angular:dspace-7_x . +docker build -t dspace/dspace-angular:latest . ``` This image is built *automatically* after each commit is made to the `main` branch. Admins to our DockerHub repo can manually publish with the following command. ``` -docker push dspace/dspace-angular:dspace-7_x +docker push dspace/dspace-angular:latest ``` ### Dockerfile.dist @@ -39,7 +39,7 @@ The `Dockerfile.dist` is used to generate a *production* build and runtime envir ```bash # build the latest image -docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . +docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist . ``` A default/demo version of this image is built *automatically*. @@ -101,8 +101,8 @@ and the backend at http://localhost:8080/server/ ## Run DSpace Angular dist build with DSpace Demo site backend -This allows you to run the Angular UI in *production* mode, pointing it at the demo backend -(https://api7.dspace.org/server/). +This allows you to run the Angular UI in *production* mode, pointing it at the demo or sandbox backend +(https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/). ``` docker-compose -f docker/docker-compose-dist.yml pull diff --git a/docker/cli.yml b/docker/cli.yml index 54b83d4503..223ec356b9 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -16,7 +16,7 @@ version: "3.7" services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" + image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" container_name: dspace-cli environment: # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 9ec8fe664a..edbb5b0759 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -35,7 +35,7 @@ services: solr__D__statistics__P__autoCommit: 'false' depends_on: - dspacedb - image: dspace/dspace:dspace-7_x-test + image: dspace/dspace:latest-test networks: dspacenet: ports: diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 1c75539da9..38278085cd 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -24,10 +24,10 @@ services: # This is because Server Side Rendering (SSR) currently requires a public URL, # see this bug: https://github.com/DSpace/dspace-angular/issues/1485 DSPACE_REST_SSL: 'true' - DSPACE_REST_HOST: api7.dspace.org + DSPACE_REST_HOST: sandbox.dspace.org DSPACE_REST_PORT: 443 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x-dist + image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist build: context: .. dockerfile: Dockerfile.dist diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index e5f62600e7..ea766600ef 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -39,7 +39,7 @@ services: # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' - image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}" + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb networks: @@ -82,7 +82,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}" + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" # Needs main 'dspace' container to start first to guarantee access to solr_configs depends_on: - dspace diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1387b1de39..1071b8d6ce 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,7 +24,7 @@ services: DSPACE_REST_HOST: localhost DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x + image: dspace/dspace-angular:${DSPACE_VER:-latest} build: context: .. dockerfile: Dockerfile diff --git a/docs/Configuration.md b/docs/Configuration.md index 62fa444cc0..01fd83c94d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -48,7 +48,7 @@ dspace-angular connects to your DSpace installation by using its REST endpoint. ```yaml rest: ssl: true - host: api7.dspace.org + host: demo.dspace.org port: 443 nameSpace: /server } @@ -57,7 +57,7 @@ rest: Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: ``` DSPACE_REST_SSL=true - DSPACE_REST_HOST=api7.dspace.org + DSPACE_REST_HOST=demo.dspace.org DSPACE_REST_PORT=443 DSPACE_REST_NAMESPACE=/server ``` diff --git a/package.json b/package.json index 59766e993b..553962dfeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "7.6.0-next", + "version": "8.0.0-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -15,14 +15,14 @@ "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build --configuration development", "build:stats": "ng build --stats-json", - "build:prod": "yarn run build:ssr", + "build:prod": "cross-env NODE_ENV=production yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "test": "ng test --source-map=true --watch=false --configuration test", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "lint": "ng lint", "lint-fix": "ng lint --fix=true", - "e2e": "ng e2e", + "e2e": "cross-env NODE_ENV=production ng e2e", "clean:dev:config": "rimraf src/assets/config.json", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", @@ -82,7 +82,7 @@ "@types/grecaptcha": "^3.0.4", "angular-idle-preload": "3.0.0", "angulartics2": "^12.2.0", - "axios": "^0.27.2", + "axios": "^1.6.0", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", @@ -99,6 +99,7 @@ "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", "http-proxy-middleware": "^1.0.5", + "http-terminator": "^3.2.0", "isbot": "^3.6.10", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", @@ -116,12 +117,12 @@ "morgan": "^1.10.0", "ng-mocks": "^14.10.0", "ng2-file-upload": "1.4.0", - "ng2-nouislider": "^1.8.3", + "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^15.0.0", "ngx-pagination": "6.0.3", "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.0.3", - "nouislider": "^14.6.3", + "nouislider": "^15.7.1", "pem": "1.14.7", "prop-types": "^15.8.1", "react-copy-to-clipboard": "^5.1.0", @@ -159,11 +160,11 @@ "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", - "axe-core": "^4.7.0", + "axe-core": "^4.7.2", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "cypress": "12.10.0", + "cypress": "12.17.4", "cypress-axe": "^1.4.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", diff --git a/scripts/webpack.js b/scripts/webpack.js deleted file mode 100644 index 93f17b4619..0000000000 --- a/scripts/webpack.js +++ /dev/null @@ -1,13 +0,0 @@ -const path = require('path'); -const child_process = require('child_process'); - -const heapSize = 4096; -const webpackPath = path.join('node_modules', 'webpack', 'bin', 'webpack.js'); - -const params = [ - '--max_old_space_size=' + heapSize, - webpackPath, - ...process.argv.slice(2) -]; - -child_process.spawn('node', params, { stdio:'inherit' }); diff --git a/server.ts b/server.ts index 3e10677a8b..da085f372f 100644 --- a/server.ts +++ b/server.ts @@ -26,15 +26,15 @@ import * as ejs from 'ejs'; import * as compression from 'compression'; import * as expressStaticGzip from 'express-static-gzip'; /* eslint-enable import/no-namespace */ - import axios from 'axios'; import LRU from 'lru-cache'; import isbot from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; +import { createHttpTerminator } from 'http-terminator'; -import { existsSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; import { enableProdMode } from '@angular/core'; @@ -54,7 +54,7 @@ import { buildAppConfig } from './src/config/config.server'; import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { logStartupMessage } from './startup-message'; -import { TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; +import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model'; /* @@ -180,6 +180,15 @@ export function app() { changeOrigin: true })); + /** + * Proxy the linksets + */ + router.use('/signposting**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); + /** * Checks if the rateLimiter property is present * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. @@ -312,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 }); } } @@ -366,9 +376,19 @@ function cacheCheck(req, res, next) { } // If cached copy exists, return it to the user. - if (cachedCopy) { + if (cachedCopy && cachedCopy.page) { + if (cachedCopy.headers) { + Object.keys(cachedCopy.headers).forEach((header) => { + if (cachedCopy.headers[header]) { + if (environment.cache.serverSide.debug) { + console.log(`Restore cached ${header} header`); + } + res.setHeader(header, cachedCopy.headers[header]); + } + }); + } res.locals.ssr = true; // mark response as SSR-generated (enables text compression) - res.send(cachedCopy); + res.send(cachedCopy.page); // Tell Express to skip all other handlers for this path // This ensures we don't try to re-render the page since we've already returned the cached copy @@ -443,22 +463,50 @@ function saveToCache(req, page: any) { const key = getCacheKey(req); // Avoid caching "/reload/[random]" paths (these are hard refreshes after logout) if (key.startsWith('/reload')) { return; } + // Avoid caching not successful responses (status code different from 2XX status) + if (hasNotSucceeded(req.res.statusCode)) { return; } + // Retrieve response headers to save, if any + const headers = retrieveHeaders(req.res); // If bot cache is enabled, save it to that cache if it doesn't exist or is expired // (NOTE: has() will return false if page is expired in cache) if (botCacheEnabled() && !botCache.has(key)) { - botCache.set(key, page); + botCache.set(key, { page, headers }); if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); } } // If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired if (anonymousCacheEnabled() && !anonymousCache.has(key)) { - anonymousCache.set(key, page); + anonymousCache.set(key, { page, headers }); if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); } } } } +/** + * Check if status code is different from 2XX + * @param statusCode + */ +function hasNotSucceeded(statusCode) { + const rgx = new RegExp(/^20+/); + return !rgx.test(statusCode); +} + +function retrieveHeaders(response) { + const headers = Object.create({}); + if (Array.isArray(environment.cache.serverSide.headers) && environment.cache.serverSide.headers.length > 0) { + environment.cache.serverSide.headers.forEach((header) => { + if (response.hasHeader(header)) { + if (environment.cache.serverSide.debug) { + console.log(`Save ${header} header to cache`); + } + headers[header] = response.getHeader(header); + } + }); + } + + return headers; +} /** * Whether a user is authenticated or not */ @@ -479,23 +527,46 @@ function serverStarted() { * @param keys SSL credentials */ function createHttpsServer(keys) { - createServer({ + const listener = createServer({ key: keys.serviceKey, cert: keys.certificate }, app).listen(environment.ui.port, environment.ui.host, () => { serverStarted(); }); + + // Graceful shutdown when signalled + const terminator = createHttpTerminator({server: listener}); + process.on('SIGINT', () => { + void (async ()=> { + console.debug('Closing HTTPS server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTPS server closed'); + })(); + }); } +/** + * Create an HTTP server with the configured port and host. + */ function run() { const port = environment.ui.port || 4000; const host = environment.ui.host || '/'; // Start up the Node server const server = app(); - server.listen(port, host, () => { + const listener = server.listen(port, host, () => { serverStarted(); }); + + // Graceful shutdown when signalled + const terminator = createHttpTerminator({server: listener}); + process.on('SIGINT', () => { + void (async () => { + console.debug('Closing HTTP server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTP server closed.');return undefined; + })(); + }); } function start() { diff --git a/src/app/access-control/access-control-routing-paths.ts b/src/app/access-control/access-control-routing-paths.ts index 259aa311e7..31f39f1c47 100644 --- a/src/app/access-control/access-control-routing-paths.ts +++ b/src/app/access-control/access-control-routing-paths.ts @@ -1,12 +1,22 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAccessControlModuleRoute } from '../app-routing-paths'; -export const GROUP_EDIT_PATH = 'groups'; +export const EPERSON_PATH = 'epeople'; + +export function getEPersonsRoute(): string { + return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString(); +} + +export function getEPersonEditRoute(id: string): string { + return new URLCombiner(getEPersonsRoute(), id, 'edit').toString(); +} + +export const GROUP_PATH = 'groups'; export function getGroupsRoute() { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); + return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString(); } export function getGroupEditRoute(id: string) { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); + return new URLCombiner(getGroupsRoute(), id, 'edit').toString(); } diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts index e64b0d170a..97d049ad83 100644 --- a/src/app/access-control/access-control-routing.module.ts +++ b/src/app/access-control/access-control-routing.module.ts @@ -3,17 +3,24 @@ import { RouterModule } from '@angular/router'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { GROUP_EDIT_PATH } from './access-control-routing-paths'; +import { EPERSON_PATH, GROUP_PATH } from './access-control-routing-paths'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { GroupPageGuard } from './group-registry/group-page.guard'; -import { 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'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; +import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; @NgModule({ imports: [ RouterModule.forChild([ { - path: 'epeople', + path: EPERSON_PATH, component: EPeopleRegistryComponent, resolve: { breadcrumb: I18nBreadcrumbResolver @@ -22,7 +29,26 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu 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 @@ -31,7 +57,7 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu canActivate: [GroupAdministratorGuard] }, { - path: `${GROUP_EDIT_PATH}/newGroup`, + path: `${GROUP_PATH}/create`, component: GroupFormComponent, resolve: { breadcrumb: I18nBreadcrumbResolver @@ -40,14 +66,23 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu canActivate: [GroupAdministratorGuard] }, { - path: `${GROUP_EDIT_PATH}/:groupId`, + path: `${GROUP_PATH}/:groupId/edit`, component: GroupFormComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, canActivate: [GroupPageGuard] - } + }, + { + path: 'bulk-access', + component: BulkAccessComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver + }, + data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, + canActivate: [SiteAdministratorGuard] + }, ]) ] }) diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts index 47a971a882..3dc4b6cedc 100644 --- a/src/app/access-control/access-control.module.ts +++ b/src/app/access-control/access-control.module.ts @@ -12,6 +12,12 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon 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 @@ -28,6 +34,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = RouterModule, AccessControlRoutingModule, FormModule, + NgbAccordionModule, + SearchModule, + AccessControlFormModule, ], exports: [ MembersListComponent, @@ -39,6 +48,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = GroupFormComponent, SubgroupsListComponent, MembersListComponent, + BulkAccessComponent, + BulkAccessBrowseComponent, + BulkAccessSettingsComponent, ], providers: [ { 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 new file mode 100644 index 0000000000..c716aedb8b --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html @@ -0,0 +1,67 @@ + + + +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.scss similarity index 100% rename from src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss rename to src/app/access-control/bulk-access/browse/bulk-access-browse.component.scss 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 new file mode 100644 index 0000000000..87b2a8d568 --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts @@ -0,0 +1,82 @@ +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 { TranslateModule } from '@ngx-translate/core'; + +import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; +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'; + +describe('BulkAccessBrowseComponent', () => { + let component: BulkAccessBrowseComponent; + let fixture: ComponentFixture; + + const listID1 = 'id1'; + const value1 = 'Selected object'; + const value2 = 'Another selected object'; + + const selected1 = new SelectableObject(value1); + const selected2 = new SelectableObject(value2); + + const testSelection = { id: listID1, selection: [selected1, selected2] } ; + + const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + NgbNavModule, + TranslateModule.forRoot() + ], + declarations: [BulkAccessBrowseComponent], + providers: [ { provide: SelectableListService, useValue: selectableListService }, ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessBrowseComponent); + component = fixture.componentInstance; + (component as any).selectableListService.getSelectableList.and.returnValue(of(testSelection)); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should have an initial active nav id of "search"', () => { + expect(component.activateId).toEqual('search'); + }); + + it('should have an initial pagination options object with default values', () => { + expect(component.paginationOptions$.getValue().id).toEqual('bas'); + expect(component.paginationOptions$.getValue().pageSize).toEqual(5); + expect(component.paginationOptions$.getValue().currentPage).toEqual(1); + }); + + it('should have an initial remote data with a paginated list as value', () => { + const list = buildPaginatedList(new PageInfo({ + 'elementsPerPage': 5, + 'totalElements': 2, + 'totalPages': 1, + 'currentPage': 1 + }), [selected1, selected2]) ; + const rd = createSuccessfulRemoteDataObject(list); + + expect(component.objectsSelected$.value).toEqual(rd); + }); + +}); 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 new file mode 100644 index 0000000000..e806e729c8 --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts @@ -0,0 +1,119 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; + +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 { 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 { hasValue } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-bulk-access-browse', + templateUrl: 'bulk-access-browse.component.html', + styleUrls: ['./bulk-access-browse.component.scss'], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class BulkAccessBrowseComponent implements OnInit, OnDestroy { + + /** + * The selection list id + */ + @Input() listId!: string; + + /** + * The active nav id + */ + activateId = 'search'; + + /** + * The list of the objects already selected + */ + objectsSelected$: BehaviorSubject>> = new BehaviorSubject>>(null); + + /** + * The pagination options object used for the list of selected elements + */ + paginationOptions$: BehaviorSubject = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { + id: 'bas', + pageSize: 5, + currentPage: 1 + })); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + constructor(private selectableListService: SelectableListService) {} + + /** + * Subscribe to selectable list updates + */ + ngOnInit(): void { + + this.subs.push( + this.selectableListService.getSelectableList(this.listId).pipe( + distinctUntilChanged(), + 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 + })); + } + + pagePrev() { + this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { + currentPage: this.paginationOptions$.value.currentPage - 1 + })); + } + + private calculatePageCount(pageSize, totalCount = 0) { + // we suppose that if we have 0 items we want 1 empty page + return totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize); + } + + /** + * Generate The RemoteData object containing the list of the selected elements + * @param list + * @private + */ + private generatePaginatedListBySelectedElements(list: SelectableListState): RemoteData> { + const pageInfo = new PageInfo({ + elementsPerPage: this.paginationOptions$.value.pageSize, + totalElements: list?.selection.length, + totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), + 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 + })); + } + return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); + } + + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.selectableListService.deselectAll(this.listId); + } +} diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html new file mode 100644 index 0000000000..382caf85f4 --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -0,0 +1,19 @@ +
+ +
+ + +
+ +
+ + +
+
+ + + diff --git a/src/app/access-control/bulk-access/bulk-access.component.scss b/src/app/access-control/bulk-access/bulk-access.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..e9b253147d --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.spec.ts @@ -0,0 +1,158 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +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 { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; + +describe('BulkAccessComponent', () => { + let component: BulkAccessComponent; + let fixture: ComponentFixture; + let bulkAccessControlService: any; + let selectableListService: any; + + const selectableListServiceMock = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); + const bulkAccessControlServiceMock = jasmine.createSpyObj('bulkAccessControlService', ['createPayloadFile', 'executeScript']); + + const mockFormState = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + const mockFile = { + 'uuids': [ + '1234', '5678' + ], + 'file': { } + }; + + const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { + getValue: jasmine.createSpy('getValue'), + reset: jasmine.createSpy('reset') + }); + const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; + const selectableListState: SelectableListState = { id: 'test', selection }; + const expectedIdList = ['1234', '5678']; + + const selectableListStateEmpty: SelectableListState = { id: 'test', selection: [] }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot() + ], + declarations: [ BulkAccessComponent ], + providers: [ + { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + { provide: SelectableListService, useValue: selectableListServiceMock } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessComponent); + component = fixture.componentInstance; + bulkAccessControlService = TestBed.inject(BulkAccessControlService); + selectableListService = TestBed.inject(SelectableListService); + + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe('when there are no elements selected', () => { + + beforeEach(() => { + + (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty)); + fixture.detectChanges(); + component.settings = mockSettings; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate the id list by selected elements', () => { + expect(component.objectsSelected$.value).toEqual([]); + }); + + it('should disable the execute button when there are no objects selected', () => { + expect(component.canExport()).toBe(false); + }); + + }); + + describe('when there are elements selected', () => { + + beforeEach(() => { + + (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState)); + fixture.detectChanges(); + component.settings = mockSettings; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate the id list by selected elements', () => { + expect(component.objectsSelected$.value).toEqual(expectedIdList); + }); + + it('should enable the execute button when there are objects selected', () => { + component.objectsSelected$.next(['1234']); + expect(component.canExport()).toBe(true); + }); + + it('should call the settings reset method when reset is called', () => { + component.reset(); + expect(component.settings.reset).toHaveBeenCalled(); + }); + + it('should call the bulkAccessControlService executeScript method when submit is called', () => { + (component.settings as any).getValue.and.returnValue(mockFormState); + bulkAccessControlService.createPayloadFile.and.returnValue(mockFile); + bulkAccessControlService.executeScript.and.returnValue(createSuccessfulRemoteDataObject$(new Process())); + component.objectsSelected$.next(['1234']); + component.submit(); + expect(bulkAccessControlService.executeScript).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts new file mode 100644 index 0000000000..04724614cb --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -0,0 +1,94 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; + +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'; + +@Component({ + selector: 'ds-bulk-access', + templateUrl: './bulk-access.component.html', + styleUrls: ['./bulk-access.component.scss'] +}) +export class BulkAccessComponent implements OnInit { + + /** + * The selection list id + */ + listId = 'bulk-access-list'; + + /** + * The list of the objects already selected + */ + objectsSelected$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + /** + * The SectionsDirective reference + */ + @ViewChild('dsBulkSettings') settings: BulkAccessSettingsComponent; + + constructor( + private bulkAccessControlService: BulkAccessControlService, + private selectableListService: SelectableListService + ) { + } + + ngOnInit(): void { + this.subs.push( + this.selectableListService.getSelectableList(this.listId).pipe( + distinctUntilChanged(), + map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) + ).subscribe(this.objectsSelected$) + ); + } + + canExport(): boolean { + return this.objectsSelected$.value?.length > 0; + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset(): void { + this.settings.reset(); + } + + /** + * Submit the form + * This will create a payload file and execute the script + */ + submit(): void { + const settings = this.settings.getValue(); + const bitstreamAccess = settings.bitstream; + const itemAccess = settings.item; + + const { file } = this.bulkAccessControlService.createPayloadFile({ + bitstreamAccess, + itemAccess, + state: settings.state + }); + + this.bulkAccessControlService.executeScript( + this.objectsSelected$.value || [], + file + ).subscribe(); + } + + /** + * Generate The RemoteData object containing the list of the selected elements + * @param list + * @private + */ + private generateIdListBySelectedElements(list: SelectableListState): string[] { + return list?.selection?.map((entry: any) => entry.indexableObject.uuid); + } +} 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 new file mode 100644 index 0000000000..01f36ef03f --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html @@ -0,0 +1,21 @@ + + + +
+ +
+
+ + +
+
+
+
+ + + +
+
diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.scss b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..14e0fdefb2 --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts @@ -0,0 +1,81 @@ +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; + let fixture: ComponentFixture; + const mockFormState = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { + getFormValue: jasmine.createSpy('getFormValue'), + reset: jasmine.createSpy('reset') + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgbAccordionModule, TranslateModule.forRoot()], + declarations: [BulkAccessSettingsComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.controlForm = mockControl; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should have a method to get the form value', () => { + expect(component.getValue).toBeDefined(); + }); + + it('should have a method to reset the form', () => { + expect(component.reset).toBeDefined(); + }); + + it('should return the correct form value', () => { + const expectedValue = mockFormState; + (component.controlForm as any).getFormValue.and.returnValue(mockFormState); + const actualValue = component.getValue(); + // @ts-ignore + expect(actualValue).toEqual(expectedValue); + }); + + it('should call reset on the control form', () => { + component.reset(); + expect(component.controlForm.reset).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 0000000000..eecc016245 --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts @@ -0,0 +1,34 @@ +import { 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' +}) +export class BulkAccessSettingsComponent { + + /** + * The SectionsDirective reference + */ + @ViewChild('dsAccessControlForm') controlForm: AccessControlFormContainerComponent; + + /** + * Will be used from a parent component to read the value of the form + */ + getValue() { + return this.controlForm.getFormValue(); + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset() { + this.controlForm.reset(); + } + +} 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..4979f85819 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -4,96 +4,91 @@
-
+
- + + +
+ +
+
+
+ + -
-
- -
- - - - - -
- - - - - - - - - - - - - - - - - -
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} -
- - -
-
-
- -
- - +
+ +
+ + + + + +
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} +
+ + +
+
+
+ +
+ +
diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index 4a09913862..e2cee5e935 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -203,36 +203,6 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('toggleEditEPerson', () => { - describe('when you click on first edit eperson button', () => { - beforeEach(fakeAsync(() => { - const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton')); - editButtons[0].triggerEventHandler('click', { - preventDefault: () => {/**/ - } - }); - tick(); - fixture.detectChanges(); - })); - - it('editEPerson form is toggled', () => { - const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); - ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) { - expect(component.isEPersonFormShown).toEqual(false); - } else { - expect(component.isEPersonFormShown).toEqual(true); - } - - }); - }); - - it('EPerson search section is hidden', () => { - expect(fixture.debugElement.query(By.css('#search'))).toBeNull(); - }); - }); - }); - describe('deleteEPerson', () => { describe('when you click on first delete eperson button', () => { let ePeopleIdsFoundBeforeDelete; diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index 706dcab690..4596eec98e 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -22,6 +22,7 @@ import { PageInfo } from '../../core/shared/page-info.model'; import { NoContent } from '../../core/shared/NoContent.model'; import { PaginationService } from '../../core/pagination/pagination.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths'; @Component({ selector: 'ds-epeople-registry', @@ -64,11 +65,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { currentPage: 1 }); - /** - * Whether or not to show the EPerson form - */ - isEPersonFormShown: boolean; - // The search form searchForm; @@ -114,17 +110,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ initialisePage() { this.searching$.next(true); - this.isEPersonFormShown = false; this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - if (eperson != null && eperson.id) { - this.isEPersonFormShown = true; - } - })); this.subs.push(this.ePeople$.pipe( switchMap((epeople: PaginatedList) => { if (epeople.pageInfo.totalElements > 0) { - return combineLatest([...epeople.page.map((eperson: EPerson) => { + return combineLatest(epeople.page.map((eperson: EPerson) => { return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( map((authorized) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -133,7 +123,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { return epersonDtoModel; }) ); - })]).pipe(map((dtos: EpersonDtoModel[]) => { + })).pipe(map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epeople.pageInfo, dtos); })); } else { @@ -160,14 +150,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { const query: string = data.query; const scope: string = data.scope; if (query != null && this.currentSearchQuery !== query) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { + void this.router.navigate([getEPersonsRoute()], { queryParamsHandling: 'merge' }); this.currentSearchQuery = query; this.paginationService.resetPage(this.config.id); } if (scope != null && this.currentSearchScope !== scope) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { + void this.router.navigate([getEPersonsRoute()], { queryParamsHandling: 'merge' }); this.currentSearchScope = scope; @@ -205,23 +195,6 @@ 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 */ @@ -242,7 +215,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { if (restResponse.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); } else { - this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage })); } }); } @@ -264,16 +237,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } - scrollToTop() { - (function smoothscroll() { - const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; - if (currentScroll > 0) { - window.requestAnimationFrame(smoothscroll); - window.scrollTo(0, currentScroll - (currentScroll / 8)); - } - })(); - } - /** * Reset all input-fields to be empty and search all search */ @@ -284,17 +247,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.search({query: ''}); } - /** - * This method will set everything to stale, which will cause the lists on this page to update. - */ - reset() { - this.epersonService.getBrowseEndpoint().pipe( - take(1) - ).subscribe((href: string) => { - this.requestService.setStaleByHrefSubstring(href).pipe(take(1)).subscribe(() => { - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }); - }); + getEditEPeoplePage(id: string): string { + return getEPersonEditRoute(id); } } diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 228449a8a5..6a7b8b931f 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -1,89 +1,97 @@ -
+
+
+
- -

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

-
+
- -

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

-
+ +

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

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

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

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

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

- +

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

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

+

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

+ + + +
+ + + + + + + + + + + + + + + + + +
{{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 : '-' }} +
+
+ +
+
+
+ +
+ + +
diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html index 9d02a5d837..e3293be3a0 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html @@ -1,7 +1,7 @@
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
- @@ -13,7 +13,7 @@
- diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html index fe26fd7063..0cbe19c1e0 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html @@ -5,7 +5,7 @@
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html index cf47f947cc..97d4016ec4 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html @@ -5,7 +5,7 @@
diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index 9495577c01..07c7c5bb89 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -1,7 +1,7 @@
+ + + + + + + + + \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts new file mode 100644 index 0000000000..afa565ce40 --- /dev/null +++ b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ProjectItemMetadataListElementComponent } from './project-item-metadata-list-element.component'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; + +const projectTitle = 'Lorem ipsum dolor sit amet'; +const mockItem = Object.assign(new Item(), { metadata: { 'dc.title': [{ value: projectTitle }] } }); +const virtMD = Object.assign(new MetadataValue(), { value: projectTitle }); + +const mockItemMetadataRepresentation = Object.assign(new ItemMetadataRepresentation(virtMD), mockItem); + +describe('ProjectItemMetadataListElementComponent', () => { + let comp: ProjectItemMetadataListElementComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports:[ + NgbModule + ], + declarations: [ProjectItemMetadataListElementComponent], + providers: [ + { provide: DSONameService, useValue: new DSONameServiceMock() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ProjectItemMetadataListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectItemMetadataListElementComponent); + comp = fixture.componentInstance; + comp.mdRepresentation = mockItemMetadataRepresentation; + fixture.detectChanges(); + }); + + it('should show the project\'s name as a link', () => { + const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent; + expect(linkText).toBe(projectTitle); + }); + +}); diff --git a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.ts b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.ts new file mode 100644 index 0000000000..a38a1f5cff --- /dev/null +++ b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { metadataRepresentationComponent } from '../../../../shared/metadata-representation/metadata-representation.decorator'; +import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model'; +import { ItemMetadataRepresentationListElementComponent } from '../../../../shared/object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; + +@metadataRepresentationComponent('Project', MetadataRepresentationType.Item) +@Component({ + selector: 'ds-project-item-metadata-list-element', + templateUrl: './project-item-metadata-list-element.component.html' +}) +/** + * The component for displaying an item of the type Project as a metadata field + */ +export class ProjectItemMetadataListElementComponent extends ItemMetadataRepresentationListElementComponent { + /** + * Initialize instance variables + * + * @param dsoNameService + */ + constructor( + public dsoNameService: DSONameService + ) { + super(); + } +} diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index 680e1bd79f..95b183f630 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -19,6 +19,7 @@ import { OrgUnitSearchResultGridElementComponent } from './item-grid-elements/se import { ProjectSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component'; import { PersonItemMetadataListElementComponent } from './metadata-representations/person/person-item-metadata-list-element.component'; import { OrgUnitItemMetadataListElementComponent } from './metadata-representations/org-unit/org-unit-item-metadata-list-element.component'; +import { ProjectItemMetadataListElementComponent } from './metadata-representations/project/project-item-metadata-list-element.component'; import { PersonSearchResultListSubmissionElementComponent } from './submission/item-list-elements/person/person-search-result-list-submission-element.component'; import { PersonInputSuggestionsComponent } from './submission/item-list-elements/person/person-suggestions/person-input-suggestions.component'; import { NameVariantModalComponent } from './submission/name-variant-modal/name-variant-modal.component'; @@ -36,6 +37,7 @@ const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator OrgUnitComponent, PersonComponent, + ProjectItemMetadataListElementComponent, ProjectComponent, OrgUnitListElementComponent, OrgUnitItemMetadataListElementComponent, diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html index 6872b3f609..dd2589c726 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -1,7 +1,7 @@
+ [attr.rel]="(linkType == linkTypes.ExternalLink) ? 'noopener noreferrer' : null" class="dont-break-out"> {{ 'footer.link.end-user-agreement' | translate}} -
  • +
  • {{ 'footer.link.feedback' | translate}}
  • diff --git a/src/app/footer/footer.component.spec.ts b/src/app/footer/footer.component.spec.ts index 15b289d5fb..9f0250edc4 100644 --- a/src/app/footer/footer.component.spec.ts +++ b/src/app/footer/footer.component.spec.ts @@ -15,6 +15,8 @@ import { FooterComponent } from './footer.component'; import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; import { storeModuleConfig } from '../app.reducer'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../shared/testing/authorization-service.stub'; let comp: FooterComponent; let fixture: ComponentFixture; @@ -34,7 +36,8 @@ describe('Footer component', () => { })], declarations: [FooterComponent], // declare the test component providers: [ - FooterComponent + FooterComponent, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index c4195c8eb3..f5e4c3799a 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -2,6 +2,9 @@ import { Component, Optional } from '@angular/core'; import { hasValue } from '../shared/empty.util'; import { KlaroService } from '../shared/cookies/klaro.service'; import { environment } from '../../environments/environment'; +import { Observable } from 'rxjs'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; @Component({ selector: 'ds-footer', @@ -17,8 +20,13 @@ export class FooterComponent { showTopFooter = false; showPrivacyPolicy = environment.info.enablePrivacyStatement; showEndUserAgreement = environment.info.enableEndUserAgreement; + showSendFeedback$: Observable; - constructor(@Optional() private cookies: KlaroService) { + constructor( + @Optional() private cookies: KlaroService, + private authorizationService: AuthorizationDataService, + ) { + this.showSendFeedback$ = this.authorizationService.isAuthorized(FeatureID.CanSendFeedback); } showCookieSettings() { diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.html b/src/app/forgot-password/forgot-password-email/forgot-email.component.html index 995108cdbc..aaa0c27b46 100644 --- a/src/app/forgot-password/forgot-password-email/forgot-email.component.html +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.html @@ -1,3 +1,3 @@ - - + diff --git a/src/app/header-nav-wrapper/header-navbar-wrapper.component.html b/src/app/header-nav-wrapper/header-navbar-wrapper.component.html index f99070b738..5756ad32b0 100644 --- a/src/app/header-nav-wrapper/header-navbar-wrapper.component.html +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.html @@ -1,4 +1,4 @@ -
    +
    diff --git a/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss index b02b3c378c..c1bc9c7e90 100644 --- a/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss @@ -1,4 +1,6 @@ :host { position: relative; - z-index: var(--ds-nav-z-index); + div#header-navbar-wrapper { + border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid; + } } diff --git a/src/app/header/context-help-toggle/context-help-toggle.component.ts b/src/app/header/context-help-toggle/context-help-toggle.component.ts index 6685df7106..de7c994faa 100644 --- a/src/app/header/context-help-toggle/context-help-toggle.component.ts +++ b/src/app/header/context-help-toggle/context-help-toggle.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ElementRef } from '@angular/core'; import { ContextHelpService } from '../../shared/context-help.service'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; /** @@ -15,12 +15,23 @@ import { map } from 'rxjs/operators'; export class ContextHelpToggleComponent implements OnInit { buttonVisible$: Observable; + subscriptions: Subscription[] = []; + constructor( - private contextHelpService: ContextHelpService, - ) { } + protected elRef: ElementRef, + protected contextHelpService: ContextHelpService, + ) { + } ngOnInit(): void { this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0)); + this.subscriptions.push(this.buttonVisible$.subscribe((showContextHelpToggle: boolean) => { + if (showContextHelpToggle) { + this.elRef.nativeElement.classList.remove('d-none'); + } else { + this.elRef.nativeElement.classList.add('d-none'); + } + })); } onClick() { diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 4d87983523..32b42dc8a7 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -7,12 +7,12 @@

    diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 9b0e87939d..b3202108f4 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import { ItemPageComponent } from './item-page.component'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; @@ -22,6 +22,10 @@ import { import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -36,11 +40,28 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), { isWithdrawn: true }); +const mocklink = { + href: 'http://test.org', + rel: 'rel1', + type: 'type1' +}; + +const mocklink2 = { + href: 'http://test2.org', + rel: 'rel2', + type: undefined +}; + +const mockSignpostingLinks: SignpostingLink[] = [mocklink, mocklink2]; + describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; let authorizationDataService: AuthorizationDataService; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; const mockMetadataService = { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ @@ -60,6 +81,18 @@ describe('ItemPageComponent', () => { authorizationDataService = jasmine.createSpyObj('authorizationDataService', { isAuthorized: observableOf(false), }); + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + + linkHeadService = jasmine.createSpyObj('LinkHeadService', { + addTag: jasmine.createSpy('setHeader'), + removeTag: jasmine.createSpy('removeTag'), + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -76,6 +109,10 @@ describe('ItemPageComponent', () => { { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: PLATFORM_ID, useValue: 'server' }, ], schemas: [NO_ERRORS_SCHEMA] @@ -126,6 +163,33 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); + + + it('should add link tags correctly', () => { + + expect(comp.signpostingLinks).toEqual([mocklink, mocklink2]); + + // Check if linkHeadService.addTag() was called with the correct arguments + expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length); + let expected: LinkDefinition = mockSignpostingLinks[0] as LinkDefinition; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + expected = { + href: 'http://test2.org', + rel: 'rel2' + }; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + }); + + it('should set Link header on the server', () => { + + expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', ' ; rel="rel1" ; type="type1" , ; rel="rel2" '); + }); + }); describe('when the item is withdrawn and the user is not an admin', () => { beforeEach(() => { @@ -150,6 +214,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is not withdrawn and the user is not an admin', () => { @@ -162,6 +231,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 6e0db386db..b9be6bebfb 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { isPlatformServer } from '@angular/common'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -15,6 +16,11 @@ import { getItemPageRoute } from '../item-page-routing-paths'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; /** * This component renders a simple item page. @@ -28,7 +34,7 @@ import { FeatureID } from '../../core/data/feature-authorization/feature-id'; changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) -export class ItemPageComponent implements OnInit { +export class ItemPageComponent implements OnInit, OnDestroy { /** * The item's id @@ -57,13 +63,23 @@ export class ItemPageComponent implements OnInit { itemUrl: string; + /** + * Contains a list of SignpostingLink related to the item + */ + signpostingLinks: SignpostingLink[] = []; + constructor( protected route: ActivatedRoute, - private router: Router, - private items: ItemDataService, - private authService: AuthService, - private authorizationService: AuthorizationDataService + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + @Inject(PLATFORM_ID) protected platformId: string ) { + this.initPageLinks(); } /** @@ -82,4 +98,42 @@ export class ItemPageComponent implements OnInit { this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); } + + /** + * Create page links if any are retrieved by signposting endpoint + * + * @private + */ + private initPageLinks(): void { + this.route.params.subscribe(params => { + this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + let links = ''; + this.signpostingLinks = signpostingLinks; + + signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' '); + let tag: LinkDefinition = { + href: link.href, + rel: link.rel + }; + if (isNotEmpty(link.type)) { + tag = Object.assign(tag, { + type: link.type + }); + } + this.linkHeadService.addTag(tag); + }); + + if (isPlatformServer(this.platformId)) { + this.responseService.setHeader('Link', links); + } + }); + }); + } + + ngOnDestroy(): void { + this.signpostingLinks.forEach((link: SignpostingLink) => { + this.linkHeadService.removeTag(`href='${link.href}'`); + }); + } } diff --git a/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts index b4c3da2cdc..0c4e82178f 100644 --- a/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/item-page/simple/item-types/shared/item-relationships-utils.ts @@ -5,8 +5,7 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../../core/shared/item.model'; import { - getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteData + getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { hasValue } from '../../../../shared/empty.util'; import { InjectionToken } from '@angular/core'; @@ -77,24 +76,42 @@ export const relationsToItems = (thisId: string) => * @param {string} thisId The item's id of which the relations belong to * @returns {(source: Observable) => Observable} */ -export const paginatedRelationsToItems = (thisId: string) => - (source: Observable>>): Observable>> => +export const paginatedRelationsToItems = (thisId: string) => (source: Observable>>): Observable>> => source.pipe( - getFirstSucceededRemoteData(), + getFirstCompletedRemoteData(), switchMap((relationshipsRD: RemoteData>) => { return observableCombineLatest( relationshipsRD.payload.page.map((rel: Relationship) => observableCombineLatest([ - rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()), - rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())] + rel.leftItem.pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + return null; + } + }) + ), + rel.rightItem.pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + return null; + } + }) + ), + ] ) - )).pipe( + ) + ).pipe( map((arr) => - arr - .map(([leftItem, rightItem]) => { - if (leftItem.id === thisId) { + arr.map(([leftItem, rightItem]) => { + if (hasValue(leftItem) && leftItem.id === thisId) { return rightItem; - } else if (rightItem.id === thisId) { + } else if (hasValue(rightItem) && rightItem.id === thisId) { return leftItem; } }) diff --git a/src/app/item-page/simple/related-entities/related-entities-search/related-entities-search.component.html b/src/app/item-page/simple/related-entities/related-entities-search/related-entities-search.component.html index 2a08efeb2c..36340bebfa 100644 --- a/src/app/item-page/simple/related-entities/related-entities-search/related-entities-search.component.html +++ b/src/app/item-page/simple/related-entities/related-entities-search/related-entities-search.component.html @@ -2,5 +2,6 @@ [fixedFilterQuery]="fixedFilter" [configuration]="configuration" [searchEnabled]="searchEnabled" - [sideBarWidth]="sideBarWidth"> + [sideBarWidth]="sideBarWidth" + [showCsvExport]="true"> diff --git a/src/app/item-page/versions/item-versions.component.ts b/src/app/item-page/versions/item-versions.component.ts index 700a35552c..e7ee9d5ea2 100644 --- a/src/app/item-page/versions/item-versions.component.ts +++ b/src/app/item-page/versions/item-versions.component.ts @@ -23,7 +23,7 @@ import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { VersionHistoryDataService } from '../../core/data/version-history-data.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; -import { AlertType } from '../../shared/alert/aletr-type'; +import { AlertType } from '../../shared/alert/alert-type'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { hasValue, hasValueOperator } from '../../shared/empty.util'; import { PaginationService } from '../../core/pagination/pagination.service'; diff --git a/src/app/item-page/versions/notice/item-versions-notice.component.ts b/src/app/item-page/versions/notice/item-versions-notice.component.ts index 8a8f5ff76f..0e5e45806b 100644 --- a/src/app/item-page/versions/notice/item-versions-notice.component.ts +++ b/src/app/item-page/versions/notice/item-versions-notice.component.ts @@ -12,7 +12,7 @@ import { } from '../../../core/shared/operators'; import { map, startWith, switchMap } from 'rxjs/operators'; import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; -import { AlertType } from '../../../shared/alert/aletr-type'; +import { AlertType } from '../../../shared/alert/alert-type'; import { getItemPageRoute } from '../../item-page-routing-paths'; @Component({ diff --git a/src/app/login-page/login-page.component.html b/src/app/login-page/login-page.component.html index 2a95e0ce1c..c38444bec8 100644 --- a/src/app/login-page/login-page.component.html +++ b/src/app/login-page/login-page.component.html @@ -3,8 +3,8 @@

    {{"login.form.header" | translate}}

    - +
    diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 57027758b1..b6c0d5e79b 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -660,6 +660,17 @@ export class MenuResolver implements Resolve { link: '/access-control/groups' } as LinkMenuItemModel, }, + { + id: 'access_control_bulk', + parentID: 'access_control', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_bulk', + link: '/access-control/bulk-access' + } as LinkMenuItemModel, + }, // TODO: enable this menu item once the feature has been implemented // { // id: 'access_control_authorizations', diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index b502326164..053968834e 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -14,9 +14,9 @@
    diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss index 65de77b600..28db981f11 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss @@ -6,14 +6,20 @@ } .dropdown-menu { + background-color: var(--ds-expandable-navbar-bg); overflow: hidden; min-width: 100%; border-top-left-radius: 0; border-top-right-radius: 0; ::ng-deep a.nav-link { + color: var(--ds-expandable-navbar-link-color) !important; padding-right: var(--bs-spacer); padding-left: var(--bs-spacer); white-space: nowrap; + + &:hover, &:focus { + color: var(--ds-expandable-navbar-link-color-hover) !important; + } } } diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 5bc69bcbb4..d32fa46a32 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -4,7 +4,6 @@ import { MenuService } from '../../shared/menu/menu.service'; import { slide } from '../../shared/animations/slide'; import { first } from 'rxjs/operators'; import { HostWindowService } from '../../shared/host-window.service'; -import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator'; import { MenuID } from '../../shared/menu/menu-id.model'; /** @@ -16,7 +15,6 @@ import { MenuID } from '../../shared/menu/menu-id.model'; styleUrls: ['./expandable-navbar-section.component.scss'], animations: [slide] }) -@rendersSectionForMenu(MenuID.PUBLIC, true) export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit { /** * This section resides in the Public Navbar diff --git a/src/app/navbar/expandable-navbar-section/themed-expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/themed-expandable-navbar-section.component.ts index e33dca4104..8f474e9949 100644 --- a/src/app/navbar/expandable-navbar-section/themed-expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/themed-expandable-navbar-section.component.ts @@ -8,8 +8,7 @@ import { MenuID } from '../../shared/menu/menu-id.model'; * Themed wrapper for ExpandableNavbarSectionComponent */ @Component({ - /* eslint-disable @angular-eslint/component-selector */ - selector: 'li[ds-themed-expandable-navbar-section]', + selector: 'ds-themed-expandable-navbar-section', styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html', }) diff --git a/src/app/navbar/navbar-section/navbar-section.component.ts b/src/app/navbar/navbar-section/navbar-section.component.ts index 9f75a96f6e..9b86aa10f2 100644 --- a/src/app/navbar/navbar-section/navbar-section.component.ts +++ b/src/app/navbar/navbar-section/navbar-section.component.ts @@ -8,8 +8,7 @@ import { MenuID } from '../../shared/menu/menu-id.model'; * Represents a non-expandable section in the navbar */ @Component({ - /* eslint-disable @angular-eslint/component-selector */ - selector: 'li[ds-navbar-section]', + selector: 'ds-navbar-section', templateUrl: './navbar-section.component.html', styleUrls: ['./navbar-section.component.scss'] }) diff --git a/src/app/navbar/navbar.component.html b/src/app/navbar/navbar.component.html index bc1e04f513..b691cfb3f9 100644 --- a/src/app/navbar/navbar.component.html +++ b/src/app/navbar/navbar.component.html @@ -6,11 +6,11 @@
    diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss index 441ee82c96..dac8c0927f 100644 --- a/src/app/navbar/navbar.component.scss +++ b/src/app/navbar/navbar.component.scss @@ -1,5 +1,5 @@ nav.navbar { - border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid; + background-color: var(--ds-navbar-bg); align-items: baseline; } @@ -11,9 +11,11 @@ nav.navbar { position: absolute; overflow: hidden; height: 0; + z-index: var(--ds-nav-z-index); &.open { height: auto; min-height: 100vh; //doesn't matter because wrapper is sticky + border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid; // open navbar covers header-navbar-wrapper border } } } @@ -38,8 +40,9 @@ nav.navbar { .navbar-nav { ::ng-deep a.nav-link { color: var(--ds-navbar-link-color); - } - ::ng-deep a.nav-link:hover { - color: var(--ds-navbar-link-color-hover); + + &:hover, &:focus { + color: var(--ds-navbar-link-color-hover); + } } } diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts index ada9be9d0b..983eace055 100644 --- a/src/app/navbar/navbar.component.spec.ts +++ b/src/app/navbar/navbar.component.spec.ts @@ -16,7 +16,6 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BrowseService } from '../core/browse/browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { buildPaginatedList } from '../core/data/paginated-list.model'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator'; import { Item } from '../core/shared/item.model'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; @@ -28,6 +27,9 @@ import { authReducer } from '../core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonMock } from '../shared/testing/eperson.mock'; +import { FlatBrowseDefinition } from '../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../core/shared/hierarchical-browse-definition.model'; let comp: NavbarComponent; let fixture: ComponentFixture; @@ -66,30 +68,35 @@ describe('NavbarComponent', () => { beforeEach(waitForAsync(() => { browseDefinitions = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), + Object.assign( + new HierarchicalBrowseDefinition(), { + id: 'srsc', + } + ), ]; initialState = { core: { diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 29cbfc113f..5f905cbfff 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -1,10 +1,15 @@ -
    -
    -

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

    +
    +
    +
    +

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

    +
    +
    + Refreshing in {{ seconds }}s +
    +
    {{ process?.scriptName }}
    @@ -17,10 +22,12 @@
    +
    - {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) + {{getFileName(file)}} + ({{(file?.sizeBytes) | dsFileSize }}) +
    @@ -70,7 +77,7 @@ -
    +
    - -
    - {{ 'grant-deny-request-copy.email.message.empty' | translate }} -
    +
    diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts index b9d51f710d..32fef125ea 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts @@ -123,19 +123,6 @@ describe('GrantRequestCopyComponent', () => { spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); }); - it('message$ should be parameterized correctly', (done) => { - component.message$.subscribe(() => { - expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ - recipientName: itemRequest.requestName, - itemUrl: itemUrl, - itemName: itemName, - authorName: user.name, - authorEmail: user.email, - })); - done(); - }); - }); - describe('grant', () => { let email: RequestCopyEmail; diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts index 79bae360a0..baf078df76 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -9,12 +9,6 @@ import { import { RemoteData } from '../../core/data/remote-data'; import { AuthService } from '../../core/auth/auth.service'; import { TranslateService } from '@ngx-translate/core'; -import { combineLatest as observableCombineLatest } from 'rxjs'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { Item } from '../../core/shared/item.model'; -import { isNotEmpty } from '../../shared/empty.util'; import { ItemRequestDataService } from '../../core/data/item-request-data.service'; import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -54,8 +48,6 @@ export class GrantRequestCopyComponent implements OnInit { private route: ActivatedRoute, private authService: AuthService, private translateService: TranslateService, - private itemDataService: ItemDataService, - private nameService: DSONameService, private itemRequestService: ItemRequestDataService, private notificationsService: NotificationsService, ) { @@ -69,31 +61,7 @@ export class GrantRequestCopyComponent implements OnInit { redirectOn4xx(this.router, this.authService), ); - const msgParams$ = observableCombineLatest([ - this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), - this.authService.getAuthenticatedUserFromStore(), - ]).pipe( - switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { - return this.itemDataService.findById(itemRequest.itemId).pipe( - getFirstSucceededRemoteDataPayload(), - map((item: Item) => { - const uri = item.firstMetadataValue('dc.identifier.uri'); - return Object.assign({ - recipientName: itemRequest.requestName, - itemUrl: isNotEmpty(uri) ? uri : item.handle, - itemName: this.nameService.getName(item), - authorName: this.nameService.getName(user), - authorEmail: user.email, - }); - }), - ); - }), - ); - this.subject$ = this.translateService.get('grant-request-copy.email.subject'); - this.message$ = msgParams$.pipe( - switchMap((params) => this.translateService.get('grant-request-copy.email.message', params)), - ); } /** diff --git a/src/app/request-copy/grant-request-copy/themed-grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/themed-grant-request-copy.component.ts new file mode 100644 index 0000000000..625dcef57a --- /dev/null +++ b/src/app/request-copy/grant-request-copy/themed-grant-request-copy.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from 'src/app/shared/theme-support/themed.component'; +import { GrantRequestCopyComponent } from './grant-request-copy.component'; + +/** + * Themed wrapper for grant-request-copy.component + */ +@Component({ + selector: 'ds-themed-grant-request-copy', + styleUrls: [], + templateUrl: './../../shared/theme-support/themed.component.html', +}) + +export class ThemedGrantRequestCopyComponent extends ThemedComponent { + protected getComponentName(): string { + return 'GrantRequestCopyComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/request-copy/grant-request-copy/grant-request-copy.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./grant-request-copy.component'); + } +} diff --git a/src/app/request-copy/request-copy-routing.module.ts b/src/app/request-copy/request-copy-routing.module.ts index e7a205d0aa..4138fc42a6 100644 --- a/src/app/request-copy/request-copy-routing.module.ts +++ b/src/app/request-copy/request-copy-routing.module.ts @@ -3,8 +3,8 @@ import { RouterModule } from '@angular/router'; import { RequestCopyResolver } from './request-copy.resolver'; import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; import { REQUEST_COPY_DENY_PATH, REQUEST_COPY_GRANT_PATH } from './request-copy-routing-paths'; -import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; -import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; +import { ThemedDenyRequestCopyComponent } from './deny-request-copy/themed-deny-request-copy.component'; +import { ThemedGrantRequestCopyComponent } from './grant-request-copy/themed-grant-request-copy.component'; @NgModule({ imports: [ @@ -21,11 +21,11 @@ import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-co }, { path: REQUEST_COPY_DENY_PATH, - component: DenyRequestCopyComponent, + component: ThemedDenyRequestCopyComponent, }, { path: REQUEST_COPY_GRANT_PATH, - component: GrantRequestCopyComponent, + component: ThemedGrantRequestCopyComponent, }, ] } diff --git a/src/app/request-copy/request-copy.module.ts b/src/app/request-copy/request-copy.module.ts index d55d5ad83f..90d741a879 100644 --- a/src/app/request-copy/request-copy.module.ts +++ b/src/app/request-copy/request-copy.module.ts @@ -4,8 +4,11 @@ import { SharedModule } from '../shared/shared.module'; import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; import { RequestCopyRoutingModule } from './request-copy-routing.module'; import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { ThemedDenyRequestCopyComponent } from './deny-request-copy/themed-deny-request-copy.component'; import { EmailRequestCopyComponent } from './email-request-copy/email-request-copy.component'; +import { ThemedEmailRequestCopyComponent } from './email-request-copy/themed-email-request-copy.component'; import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; +import { ThemedGrantRequestCopyComponent } from './grant-request-copy/themed-grant-request-copy.component'; @NgModule({ imports: [ @@ -16,8 +19,14 @@ import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-co declarations: [ GrantDenyRequestCopyComponent, DenyRequestCopyComponent, + ThemedDenyRequestCopyComponent, EmailRequestCopyComponent, + ThemedEmailRequestCopyComponent, GrantRequestCopyComponent, + ThemedGrantRequestCopyComponent, + ], + exports: [ + ThemedEmailRequestCopyComponent, ], providers: [] }) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index bf49e507c0..d6d0586892 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -1,3 +1,7 @@ + +
    -
    - + +
    diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index e69de29bb2..9eb198417a 100644 --- a/src/app/root/root.component.scss +++ b/src/app/root/root.component.scss @@ -0,0 +1,16 @@ +#skip-to-main-content { + position: absolute; + top: -40px; + left: 0; + opacity: 0; + transition: opacity 0.3s; + z-index: calc(var(--ds-nav-z-index) + 1); + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 0; + + &:focus { + opacity: 1; + top: 0; + } +} diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 3c2d65fc1f..160504f14f 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -13,7 +13,7 @@ import { AuthService } from '../core/auth/auth.service'; import { CSSVariableService } from '../shared/sass-helper/css-variable.service'; import { MenuService } from '../shared/menu/menu.service'; import { HostWindowService } from '../shared/host-window.service'; -import { ThemeConfig } from '../../config/theme.model'; +import { ThemeConfig } from '../../config/theme.config'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { environment } from '../../environments/environment'; import { slideSidebarPadding } from '../shared/animations/slide'; @@ -78,4 +78,12 @@ export class RootComponent implements OnInit { this.shouldShowRouteLoader = false; } } + + skipToMainContent() { + const mainContent = document.getElementById('main-content'); + if (mainContent) { + mainContent.tabIndex = -1; + mainContent.focus(); + } + } } diff --git a/src/app/search-navbar/search-navbar.component.html b/src/app/search-navbar/search-navbar.component.html index 2b30507f3e..02a9890502 100644 --- a/src/app/search-navbar/search-navbar.component.html +++ b/src/app/search-navbar/search-navbar.component.html @@ -1,9 +1,12 @@
    -
    + + class="bg-transparent position-absolute form-control dropdown-menu-right pl-1 pr-4" + [class.display]="searchExpanded ? 'inline-block' : 'none'" + [tabIndex]="searchExpanded ? 0 : -1" + [attr.data-test]="'header-search-box' | dsBrowserOnly"> diff --git a/src/app/search-navbar/search-navbar.component.scss b/src/app/search-navbar/search-navbar.component.scss index cf46c25d91..a276482b53 100644 --- a/src/app/search-navbar/search-navbar.component.scss +++ b/src/app/search-navbar/search-navbar.component.scss @@ -12,6 +12,7 @@ input[type="text"] { cursor: pointer; position: sticky; top: 0; + border: 0 !important; color: var(--ds-header-icon-color); &:hover, &:focus { diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html new file mode 100644 index 0000000000..cd56904bd7 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html @@ -0,0 +1,111 @@ + +
    + {{'access-control-no-access-conditions-warning-message' | translate}} +
    + + +
    + +
    +
    + + + + {{'access-control-option-note' | translate}} + +
    + +
    + +
    + +
    + +
    +
    + + {{'access-control-option-start-date-note' | translate}} + +
    + +
    + +
    + +
    + +
    +
    + + {{'access-control-option-end-date-note' | translate}} + +
    +
    + +
    + +
    + + +
    +
    +
    +
    + + + +
    diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss new file mode 100644 index 0000000000..43cc0789b2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss @@ -0,0 +1,7 @@ +.access-control-item { + display: grid; + grid-template-columns: 1fr 50px; + grid-gap: 10px; + padding-bottom: 15px; + border-bottom: 2px dotted grey; +} diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts new file mode 100644 index 0000000000..964eb30de2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccessControlArrayFormComponent } from './access-control-array-form.component'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ToDatePipe } from './to-date.pipe'; +import { SharedBrowseByModule } from '../../browse-by/shared-browse-by.module'; + +describe('AccessControlArrayFormComponent', () => { + let component: AccessControlArrayFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ CommonModule, FormsModule, SharedBrowseByModule, TranslateModule.forRoot(), NgbDatepickerModule ], + declarations: [ AccessControlArrayFormComponent, ToDatePipe ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccessControlArrayFormComponent); + component = fixture.componentInstance; + component.dropdownOptions = [{name: 'Option1'}, {name: 'Option2'}] as any; + component.type = 'item'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have only one empty control access item in the form', () => { + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(1); + }); + + it('should add access control item', () => { + component.addAccessControlItem(); + expect(component.form.accessControls.length).toEqual(2); + }); + + it('should remove access control item', () => { + expect(component.form.accessControls.length).toEqual(1); + + component.addAccessControlItem(); + expect(component.form.accessControls.length).toEqual(2); + + const id = component.form.accessControls[0].id; + component.removeAccessControlItem(id); + expect(component.form.accessControls.length).toEqual(1); + }); + + it('should reset form value', () => { + const item = { itemName: 'item1', startDate: '2022-01-01', endDate: '2022-02-01' }; + component.addAccessControlItem(item.itemName); + + // set value to item1 + component.accessControlChanged( + component.form.accessControls[1], + 'item1' + ); + + component.reset(); + expect(component.form.accessControls[1]?.itemName).toEqual(undefined); + }); + + + it('should display a select dropdown with options', () => { + component.enable(); + fixture.detectChanges(); + + const id = component.form.accessControls[0].id; + + const selectElement: DebugElement = fixture.debugElement.query(By.css(`select#accesscontroloption-${id}`)); + expect(selectElement).toBeTruthy(); + + const options = selectElement.nativeElement.querySelectorAll('option'); + expect(options.length).toEqual(3); // 2 options + default empty option + + expect(options[0].value).toEqual(''); + expect(options[1].value).toEqual('Option1'); + expect(options[2].value).toEqual('Option2'); + }); + + it('should add new access control items when clicking "Add more" button', () => { + component.enable(); + fixture.detectChanges(); + + const addButton: DebugElement = fixture.debugElement.query(By.css(`button#add-btn-${component.type}`)); + addButton.nativeElement.click(); + fixture.detectChanges(); + + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(2); + }); + + it('should remove access control items when clicking remove button', () => { + component.enable(); + + component.addAccessControlItem('test'); + + fixture.detectChanges(); + + const removeButton: DebugElement[] = fixture.debugElement.queryAll(By.css('button.btn-outline-danger')); + removeButton[1].nativeElement.click(); + fixture.detectChanges(); + + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(1); + }); +}); diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts new file mode 100644 index 0000000000..227de596ff --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts @@ -0,0 +1,150 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {NgForm} from '@angular/forms'; +import {AccessesConditionOption} from '../../../core/config/models/config-accesses-conditions-options.model'; +import {dateToISOFormat} from '../../date.util'; + +@Component({ + selector: 'ds-access-control-array-form', + templateUrl: './access-control-array-form.component.html', + styleUrls: ['./access-control-array-form.component.scss'], + exportAs: 'accessControlArrayForm' +}) +export class AccessControlArrayFormComponent implements OnInit { + @Input() dropdownOptions: AccessesConditionOption[] = []; + @Input() mode!: 'add' | 'replace'; + @Input() type!: 'item' | 'bitstream'; + + @ViewChild('ngForm', {static: true}) ngForm!: NgForm; + + form: { accessControls: AccessControlItem[] } = { + accessControls: [emptyAccessControlItem()] // Start with one empty access control item + }; + + formDisabled = true; + + ngOnInit(): void { + this.disable(); // Disable the form by default + } + + get allControlsAreEmpty() { + return this.form.accessControls + .every(x => x.itemName === null || x.itemName === ''); + } + + get showWarning() { + return this.mode === 'replace' && this.allControlsAreEmpty && !this.formDisabled; + } + + /** + * Add a new access control item to the form. + * Start and end date are disabled by default. + * @param itemName The name of the item to add + */ + addAccessControlItem(itemName: string = null) { + this.form.accessControls = [ + ...this.form.accessControls, + {...emptyAccessControlItem(), itemName} + ]; + } + + /** + * Remove an access control item from the form. + * @param ngModelGroup + * @param index + */ + removeAccessControlItem(id: number) { + this.form.accessControls = this.form.accessControls.filter(item => item.id !== id); + } + + /** + * Get the value of the form. + * This will be used to read the form value from the parent component. + * @return The form value + */ + getValue() { + return this.form.accessControls + .filter(x => x.itemName !== null && x.itemName !== '') + .map(x => ({ + name: x.itemName, + startDate: (x.startDate ? dateToISOFormat(x.startDate) : null), + endDate: (x.endDate ? dateToISOFormat(x.endDate) : null) + })); + } + + /** + * Set the value of the form from the parent component. + */ + reset() { + this.form.accessControls = []; + + // Add an empty access control item by default + this.addAccessControlItem(); + + this.disable(); + } + + /** + * Disable the form. + * This will be used to disable the form from the parent component. + */ + disable = () => { + this.ngForm.form.disable(); + this.formDisabled = true; + }; + + /** + * Enable the form. + * This will be used to enable the form from the parent component. + */ + enable = () => { + this.ngForm.form.enable(); + this.formDisabled = false; + }; + + accessControlChanged(control: AccessControlItem, selectedItem: string) { + const item = this.dropdownOptions + .find((x) => x.name === selectedItem); + + control.startDate = null; + control.endDate = null; + + control.hasStartDate = item?.hasStartDate || false; + control.hasEndDate = item?.hasEndDate || false; + + control.maxStartDate = item?.maxStartDate || null; + control.maxEndDate = item?.maxEndDate || null; + } + + trackById(index: number, item: AccessControlItem) { + return item.id; + } + +} + + +export interface AccessControlItem { + id: number; // will be used only locally + + itemName: string | null; + + hasStartDate?: boolean; + startDate: string | null; + maxStartDate?: string | null; + + hasEndDate?: boolean; + endDate: string | null; + maxEndDate?: string | null; +} + +const emptyAccessControlItem = (): AccessControlItem => ({ + id: randomID(), + itemName: null, + startDate: null, + hasStartDate: false, + maxStartDate: null, + endDate: null, + hasEndDate: false, + maxEndDate: null, +}); + +const randomID = () => Math.floor(Math.random() * 1000000); diff --git a/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts b/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts new file mode 100644 index 0000000000..203d12a59d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts @@ -0,0 +1,23 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {NgbDateStruct} from '@ng-bootstrap/ng-bootstrap/datepicker/ngb-date-struct'; + +@Pipe({ + // eslint-disable-next-line @angular-eslint/pipe-prefix + name: 'toDate', + pure: false +}) +export class ToDatePipe implements PipeTransform { + transform(dateValue: string | null): NgbDateStruct | null { + if (!dateValue) { + return null; + } + + const date = new Date(dateValue); + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate() + } as NgbDateStruct; + } + +} diff --git a/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts b/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts new file mode 100644 index 0000000000..b5081db6c2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts @@ -0,0 +1,27 @@ +import {ListableObject} from '../object-collection/shared/listable-object.model'; + +export const createAccessControlInitialFormState = (): AccessControlFormState => ({ + item: { + toggleStatus: false, + accessMode: 'replace', + }, + bitstream: { + toggleStatus: false, + accessMode: 'replace', + changesLimit: 'all', // 'all' | 'selected' + selectedBitstreams: [] as ListableObject[], + }, +}); + +export interface AccessControlFormState { + item: { + toggleStatus: boolean, + accessMode: 'add' | 'replace', + }, + bitstream: { + toggleStatus: boolean, + accessMode: 'add' | 'replace', + changesLimit: string, + selectedBitstreams: ListableObject[], + } +} diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.html b/src/app/shared/access-control-form-container/access-control-form-container.component.html new file mode 100644 index 0000000000..a5173d10d7 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.html @@ -0,0 +1,167 @@ +
    +
    +
    + + + +
    +
    + +
    +
    +

    + {{ 'access-control-item-header-toggle' | translate }} +

    + + +
    + +
    +
    + {{ 'access-control-mode' | translate }} +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    {{'access-control-access-conditions' | translate}}
    + + + +
    + +
    + +
    + +
    +
    +

    + {{'access-control-bitstream-header-toggle' | translate}} +

    + + +
    + +
    +
    + {{'access-control-limit-to-specific' | translate}} +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    + {{'access-control-mode' | translate}} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    {{'access-control-access-conditions' | translate}}
    + + + +
    +
    +
    + +
    + +
    + + +
    +
    +
    +
    diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.scss b/src/app/shared/access-control-form-container/access-control-form-container.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts b/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts new file mode 100644 index 0000000000..4d02f7a52d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts @@ -0,0 +1,149 @@ +import {ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing'; +import {NgbDatepickerModule, NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {Component} from '@angular/core'; +import {of} from 'rxjs'; +import {AccessControlFormContainerComponent} from './access-control-form-container.component'; +import {BulkAccessControlService} from './bulk-access-control.service'; +import {BulkAccessConfigDataService} from '../../core/config/bulk-access-config-data.service'; +import {Item} from '../../core/shared/item.model'; +import {SelectableListService} from '../object-list/selectable-list/selectable-list.service'; +import {createAccessControlInitialFormState} from './access-control-form-container-intial-state'; +import {CommonModule} from '@angular/common'; +import {SharedBrowseByModule} from '../browse-by/shared-browse-by.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {FormsModule} from '@angular/forms'; +import {UiSwitchModule} from 'ngx-ui-switch'; +import { + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import {AccessControlFormModule} from './access-control-form.module'; + + +describe('AccessControlFormContainerComponent', () => { + let component: AccessControlFormContainerComponent; + let fixture: ComponentFixture>; + + +// Mock NgbModal + @Component({selector: 'ds-ngb-modal', template: ''}) + class MockNgbModalComponent { + } + +// Mock dependencies + const mockBulkAccessControlService = { + createPayloadFile: jasmine.createSpy('createPayloadFile').and.returnValue({file: 'mocked-file'}), + executeScript: jasmine.createSpy('executeScript').and.returnValue(of('success')), + }; + + const mockBulkAccessConfigDataService = { + findByName: jasmine.createSpy('findByName').and.returnValue(of({payload: {options: []}})), + }; + + const mockSelectableListService = { + getSelectableList: jasmine.createSpy('getSelectableList').and.returnValue(of({selection: []})), + deselectAll: jasmine.createSpy('deselectAll'), + }; + + const mockNgbModal = { + open: jasmine.createSpy('open').and.returnValue( + { componentInstance: {}, closed: of({})} as NgbModalRef + ) + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AccessControlFormContainerComponent, MockNgbModalComponent], + imports: [ + CommonModule, + FormsModule, + SharedBrowseByModule, + AccessControlFormModule, + TranslateModule.forRoot(), + NgbDatepickerModule, + UiSwitchModule + ], + providers: [ + {provide: BulkAccessControlService, useValue: mockBulkAccessControlService}, + {provide: BulkAccessConfigDataService, useValue: mockBulkAccessConfigDataService}, + {provide: SelectableListService, useValue: mockSelectableListService}, + {provide: NgbModal, useValue: mockNgbModal}, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccessControlFormContainerComponent); + component = fixture.componentInstance; + component.state = createAccessControlInitialFormState(); + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should reset the form', fakeAsync(() => { + fixture.detectChanges(); + const resetSpy = spyOn(component.bitstreamAccessCmp, 'reset'); + spyOn(component.itemAccessCmp, 'reset'); + + component.reset(); + + expect(resetSpy).toHaveBeenCalled(); + expect(component.itemAccessCmp.reset).toHaveBeenCalled(); + expect(component.state).toEqual(createAccessControlInitialFormState()); + })); + + it('should submit the form', () => { + const bitstreamAccess = 'bitstreamAccess'; + const itemAccess = 'itemAccess'; + component.bitstreamAccessCmp.getValue = jasmine.createSpy('getValue').and.returnValue(bitstreamAccess); + component.itemAccessCmp.getValue = jasmine.createSpy('getValue').and.returnValue(itemAccess); + component.itemRD = {payload: {uuid: 'item-uuid'}} as any; + + component.submit(); + + expect(mockBulkAccessControlService.createPayloadFile).toHaveBeenCalledWith({ + bitstreamAccess, + itemAccess, + state: createAccessControlInitialFormState(), + }); + expect(mockBulkAccessControlService.executeScript).toHaveBeenCalledWith(['item-uuid'], 'mocked-file'); + }); + + it('should handle the status change for bitstream access', () => { + component.bitstreamAccessCmp.enable = jasmine.createSpy('enable'); + component.bitstreamAccessCmp.disable = jasmine.createSpy('disable'); + + component.handleStatusChange('bitstream', true); + expect(component.bitstreamAccessCmp.enable).toHaveBeenCalled(); + + component.handleStatusChange('bitstream', false); + expect(component.bitstreamAccessCmp.disable).toHaveBeenCalled(); + }); + + it('should handle the status change for item access', () => { + component.itemAccessCmp.enable = jasmine.createSpy('enable'); + component.itemAccessCmp.disable = jasmine.createSpy('disable'); + + component.handleStatusChange('item', true); + expect(component.itemAccessCmp.enable).toHaveBeenCalled(); + + component.handleStatusChange('item', false); + expect(component.itemAccessCmp.disable).toHaveBeenCalled(); + }); + + it('should open the select bitstreams modal', () => { + const modalService = TestBed.inject(NgbModal); + + component.openSelectBitstreamsModal(new Item()); + expect(modalService.open).toHaveBeenCalled(); + }); + + it('should unsubscribe and deselect all on component destroy', () => { + component.ngOnDestroy(); + expect(component.selectableListService.deselectAll).toHaveBeenCalledWith( + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID + ); + }); +}); diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.ts b/src/app/shared/access-control-form-container/access-control-form-container.component.ts new file mode 100644 index 0000000000..cddd1b1a29 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.ts @@ -0,0 +1,160 @@ +import { ChangeDetectorRef, Component, Input, OnDestroy, ViewChild } from '@angular/core'; +import { concatMap, Observable, shareReplay } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { AccessControlArrayFormComponent } from './access-control-array-form/access-control-array-form.component'; +import { BulkAccessControlService } from './bulk-access-control.service'; +import { SelectableListService } from '../object-list/selectable-list/selectable-list.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { map, take } from 'rxjs/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID, + ItemAccessControlSelectBitstreamsModalComponent +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import { BulkAccessConfigDataService } from '../../core/config/bulk-access-config-data.service'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { BulkAccessConditionOptions } from '../../core/config/models/bulk-access-condition-options.model'; +import { AlertType } from '../alert/alert-type'; +import { + createAccessControlInitialFormState +} from './access-control-form-container-intial-state'; + +@Component({ + selector: 'ds-access-control-form-container', + templateUrl: './access-control-form-container.component.html', + styleUrls: [ './access-control-form-container.component.scss' ], + exportAs: 'dsAccessControlForm' +}) +export class AccessControlFormContainerComponent implements OnDestroy { + + /** + * Will be used to determine if we need to show the limit changes to specific bitstreams radio buttons + */ + @Input() showLimitToSpecificBitstreams = false; + + /** + * The title message of the access control form (translate key) + */ + @Input() titleMessage = ''; + + /** + * The item to which the access control form applies + */ + @Input() itemRD: RemoteData; + + /** + * Whether to show the submit and cancel button + * We want to hide these buttons when the form is + * used in an accordion, and we want to show buttons somewhere else + */ + @Input() showSubmit = true; + + @ViewChild('bitstreamAccessCmp', { static: true }) bitstreamAccessCmp: AccessControlArrayFormComponent; + @ViewChild('itemAccessCmp', { static: true }) itemAccessCmp: AccessControlArrayFormComponent; + + readonly AlertType = AlertType; + + constructor( + private bulkAccessConfigService: BulkAccessConfigDataService, + private bulkAccessControlService: BulkAccessControlService, + public selectableListService: SelectableListService, + protected modalService: NgbModal, + private cdr: ChangeDetectorRef + ) {} + + state = createAccessControlInitialFormState(); + + dropdownData$: Observable = this.bulkAccessConfigService.findByName('default').pipe( + getFirstCompletedRemoteData(), + map((configRD: RemoteData) => configRD.hasSucceeded ? configRD.payload : null), + shareReplay(1) + ); + + /** + * Will be used from a parent component to read the value of the form + */ + getFormValue() { + console.log({ + bitstream: this.bitstreamAccessCmp.getValue(), + item: this.itemAccessCmp.getValue(), + state: this.state + }); + return { + bitstream: this.bitstreamAccessCmp.getValue(), + item: this.itemAccessCmp.getValue(), + state: this.state + }; + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset() { + this.bitstreamAccessCmp.reset(); + this.itemAccessCmp.reset(); + this.state = createAccessControlInitialFormState(); + } + + /** + * Submit the form + * This will create a payload file and execute the script + */ + submit() { + const bitstreamAccess = this.bitstreamAccessCmp.getValue(); + const itemAccess = this.itemAccessCmp.getValue(); + + const { file } = this.bulkAccessControlService.createPayloadFile({ + bitstreamAccess, + itemAccess, + state: this.state + }); + + this.bulkAccessControlService.executeScript( + [ this.itemRD.payload.uuid ], + file + ).pipe(take(1)).subscribe((res) => { + console.log('success', res); + }); + } + + /** + * Handle the status change of the access control form (item or bitstream) + * This will enable/disable the access control form for the item or bitstream + * @param type The type of the access control form (item or bitstream) + * @param active boolean indicating whether the access control form should be enabled or disabled + */ + handleStatusChange(type: 'item' | 'bitstream', active: boolean) { + if (type === 'bitstream') { + active ? this.bitstreamAccessCmp.enable() : this.bitstreamAccessCmp.disable(); + } else if (type === 'item') { + active ? this.itemAccessCmp.enable() : this.itemAccessCmp.disable(); + } + } + + /** + * Open the modal to select bitstreams for which to change the access control + * This will open the modal and pass the currently selected bitstreams + * @param item The item for which to change the access control + */ + openSelectBitstreamsModal(item: Item) { + const ref = this.modalService.open(ItemAccessControlSelectBitstreamsModalComponent); + ref.componentInstance.selectedBitstreams = this.state.bitstream.selectedBitstreams; + ref.componentInstance.item = item; + + ref.closed.pipe( + concatMap(() => this.selectableListService.getSelectableList(ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID)), + take(1) + ).subscribe((list) => { + this.state.bitstream.selectedBitstreams = list?.selection || []; + this.cdr.detectChanges(); + }); + } + + ngOnDestroy(): void { + this.selectableListService.deselectAll(ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID); + } + +} + diff --git a/src/app/shared/access-control-form-container/access-control-form.module.ts b/src/app/shared/access-control-form-container/access-control-form.module.ts new file mode 100644 index 0000000000..3bbdb3ab5d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form.module.ts @@ -0,0 +1,32 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +import {TranslateModule} from '@ngx-translate/core'; +import {UiSwitchModule} from 'ngx-ui-switch'; + +import {AccessControlArrayFormComponent} from './access-control-array-form/access-control-array-form.component'; +import {SharedModule} from '../shared.module'; +import { + ItemAccessControlSelectBitstreamsModalComponent +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import {AccessControlFormContainerComponent} from './access-control-form-container.component'; +import {NgbDatepickerModule} from '@ng-bootstrap/ng-bootstrap'; +import {ToDatePipe} from './access-control-array-form/to-date.pipe'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + TranslateModule, + UiSwitchModule, + NgbDatepickerModule + ], + declarations: [ + AccessControlFormContainerComponent, + AccessControlArrayFormComponent, + ItemAccessControlSelectBitstreamsModalComponent, + ToDatePipe + ], + exports: [ AccessControlFormContainerComponent, AccessControlArrayFormComponent ], +}) +export class AccessControlFormModule {} diff --git a/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts b/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts new file mode 100644 index 0000000000..16d05edeb7 --- /dev/null +++ b/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BulkAccessControlService } from './bulk-access-control.service'; +import { ScriptDataService } from '../../core/data/processes/script-data.service'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { Process } from '../../process-page/processes/process.model'; + +describe('BulkAccessControlService', () => { + let service: BulkAccessControlService; + let scriptServiceSpy: jasmine.SpyObj; + + const mockPayload: any = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + beforeEach(() => { + const spy = jasmine.createSpyObj('ScriptDataService', ['invoke']); + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot() + ], + providers: [ + BulkAccessControlService, + { provide: ScriptDataService, useValue: spy }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + ] + }); + service = TestBed.inject(BulkAccessControlService); + scriptServiceSpy = TestBed.inject(ScriptDataService) as jasmine.SpyObj; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('createPayloadFile', () => { + it('should create a file and return the URL and file object', () => { + const payload = mockPayload; + const result = service.createPayloadFile(payload); + + expect(result.url).toBeTruthy(); + expect(result.file).toBeTruthy(); + }); + }); + + describe('executeScript', () => { + it('should invoke the script service with the correct parameters', () => { + const uuids = ['123', '456']; + const file = new File(['test'], 'data.json', { type: 'application/json' }); + const expectedParams: ProcessParameter[] = [ + { name: '-f', value: 'data.json' }, + { name: '-u', value: '123' }, + { name: '-u', value: '456' }, + ]; + + // @ts-ignore + scriptServiceSpy.invoke.and.returnValue(createSuccessfulRemoteDataObject$(new Process())); + + const result = service.executeScript(uuids, file); + + expect(scriptServiceSpy.invoke).toHaveBeenCalledWith('bulk-access-control', expectedParams, [file]); + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/src/app/shared/access-control-form-container/bulk-access-control.service.ts b/src/app/shared/access-control-form-container/bulk-access-control.service.ts new file mode 100644 index 0000000000..6fb6b62532 --- /dev/null +++ b/src/app/shared/access-control-form-container/bulk-access-control.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { ScriptDataService } from '../../core/data/processes/script-data.service'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { AccessControlFormState } from './access-control-form-container-intial-state'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Process } from '../../process-page/processes/process.model'; +import { isNotEmpty } from '../empty.util'; +import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { NotificationsService } from '../notifications/notifications.service'; + +export interface BulkAccessPayload { + state: AccessControlFormState; + bitstreamAccess: any; + itemAccess: any; +} + +/** + * This service is used to create a payload file and execute the bulk access control script + */ +@Injectable({ providedIn: 'root' }) +export class BulkAccessControlService { + constructor( + private notificationsService: NotificationsService, + private router: Router, + private scriptService: ScriptDataService, + private translationService: TranslateService + ) {} + + /** + * Create a payload file from the given payload and return the file and the url to the file + * The created file will be used as input for the bulk access control script + * @param payload The payload to create the file from + */ + createPayloadFile(payload: BulkAccessPayload) { + const content = convertToBulkAccessControlFileModel(payload); + + const blob = new Blob([JSON.stringify(content, null, 2)], { + type: 'application/json', + }); + + const file = new File([blob], 'data.json', { + type: 'application/json', + }); + + const url = URL.createObjectURL(file); + + return { url, file }; + } + + /** + * Execute the bulk access control script with the given uuids and file + * @param uuids + * @param file + */ + executeScript(uuids: string[], file: File): Observable { + console.log('execute', { uuids, file }); + + const params: ProcessParameter[] = [ + { name: '-f', value: file.name } + ]; + uuids.forEach((uuid) => { + params.push({ name: '-u', value: uuid }); + }); + + return this.scriptService.invoke('bulk-access-control', params, [file]).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translationService.get('process.new.notification.success.title'); + const content = this.translationService.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } + return true; + } else { + const title = this.translationService.get('process.new.notification.error.title'); + const content = this.translationService.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + return false; + } + }) + ); + } +} + +/** + * Convert the given payload to a BulkAccessControlFileModel + * @param payload + */ +export const convertToBulkAccessControlFileModel = (payload: { state: AccessControlFormState, bitstreamAccess: AccessCondition[], itemAccess: AccessCondition[] }): BulkAccessControlFileModel => { + let finalPayload: BulkAccessControlFileModel = {}; + + const itemEnabled = payload.state.item.toggleStatus; + const bitstreamEnabled = payload.state.bitstream.toggleStatus; + + if (itemEnabled) { + finalPayload.item = { + mode: payload.state.item.accessMode, + accessConditions: payload.itemAccess + }; + } + + if (bitstreamEnabled) { + const constraints = { uuid: [] }; + + if (bitstreamEnabled && payload.state.bitstream.changesLimit === 'selected') { + // @ts-ignore + constraints.uuid = payload.state.bitstream.selectedBitstreams.map((x) => x.id); + } + + finalPayload.bitstream = { + constraints, + mode: payload.state.bitstream.accessMode, + accessConditions: payload.bitstreamAccess + }; + } + + return finalPayload; +}; + + +export interface BulkAccessControlFileModel { + item?: { + mode: string; + accessConditions: AccessCondition[]; + }, + bitstream?: { + constraints: { uuid: string[] }; + mode: string; + accessConditions: AccessCondition[]; + } +} + +interface AccessCondition { + name: string; + startDate?: string; + endDate?: string; +} diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html new file mode 100644 index 0000000000..8cf0ecea38 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html @@ -0,0 +1,35 @@ + + + diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.scss b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts new file mode 100644 index 0000000000..f60d9a70e7 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemAccessControlSelectBitstreamsModalComponent } from './item-access-control-select-bitstreams-modal.component'; + +xdescribe('ItemAccessControlSelectBitstreamsModalComponent', () => { + let component: ItemAccessControlSelectBitstreamsModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ItemAccessControlSelectBitstreamsModalComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemAccessControlSelectBitstreamsModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts new file mode 100644 index 0000000000..617803a0c4 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { PaginatedList } from 'src/app/core/data/paginated-list.model'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Context } from 'src/app/core/shared/context.model'; +import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; +import { Item } from '../../../core/shared/item.model'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue } from '../../empty.util'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; + +export const ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID = 'item-access-control-select-bitstreams'; + +@Component({ + selector: 'ds-item-access-control-select-bitstreams-modal', + templateUrl: './item-access-control-select-bitstreams-modal.component.html', + styleUrls: [ './item-access-control-select-bitstreams-modal.component.scss' ] +}) +export class ItemAccessControlSelectBitstreamsModalComponent implements OnInit { + + LIST_ID = ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID; + + @Input() item!: Item; + @Input() selectedBitstreams: string[] = []; + + data$ = new BehaviorSubject> | null>(null); + paginationConfig: PaginationComponentOptions; + pageSize = 5; + + context: Context = Context.Bitstream; + + constructor( + private bitstreamService: BitstreamDataService, + protected paginationService: PaginationService, + protected translateService: TranslateService, + public activeModal: NgbActiveModal + ) { } + + ngOnInit() { + this.loadForPage(1); + + this.paginationConfig = new PaginationComponentOptions(); + this.paginationConfig.id = 'iacsbm'; + this.paginationConfig.currentPage = 1; + if (hasValue(this.pageSize)) { + this.paginationConfig.pageSize = this.pageSize; + } + } + + loadForPage(page: number) { + this.bitstreamService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: page}, false) + .pipe( + getFirstCompletedRemoteData(), + ) + .subscribe(this.data$); + } + +} diff --git a/src/app/shared/alert/aletr-type.ts b/src/app/shared/alert/alert-type.ts similarity index 100% rename from src/app/shared/alert/aletr-type.ts rename to src/app/shared/alert/alert-type.ts diff --git a/src/app/shared/alert/alert.component.spec.ts b/src/app/shared/alert/alert.component.spec.ts index 21e4d197b7..11411c7de0 100644 --- a/src/app/shared/alert/alert.component.spec.ts +++ b/src/app/shared/alert/alert.component.spec.ts @@ -8,7 +8,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { AlertComponent } from './alert.component'; import { createTestComponent } from '../testing/utils.test'; -import { AlertType } from './aletr-type'; +import { AlertType } from './alert-type'; describe('AlertComponent test suite', () => { diff --git a/src/app/shared/alert/alert.component.ts b/src/app/shared/alert/alert.component.ts index 93535d2057..07a8efbd7d 100644 --- a/src/app/shared/alert/alert.component.ts +++ b/src/app/shared/alert/alert.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; import { trigger } from '@angular/animations'; -import { AlertType } from './aletr-type'; +import { AlertType } from './alert-type'; import { fadeOutLeave, fadeOutState } from '../animations/fade'; /** diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts index 310ddbbfde..9d18817ee3 100644 --- a/src/app/shared/animations/slide.ts +++ b/src/app/shared/animations/slide.ts @@ -55,7 +55,7 @@ export const slideSidebarPadding = trigger('slideSidebarPadding', [ export const expandSearchInput = trigger('toggleAnimation', [ state('collapsed', style({ - width: '30px', + width: '0', opacity: '0' })), state('expanded', style({ diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 05f502afa1..eba37fa416 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -2,18 +2,18 @@ @@ -22,7 +22,7 @@
    - +
    diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 58a1edfabd..0b9ea6ef4b 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -248,7 +248,7 @@ describe('AuthNavMenuComponent', () => { component = null; }); it('should render UserMenuComponent component', () => { - const logoutDropdownMenu = deNavMenuItem.query(By.css('ds-user-menu')); + const logoutDropdownMenu = deNavMenuItem.query(By.css('ds-themed-user-menu')); expect(logoutDropdownMenu.nativeElement).toBeDefined(); }); }); diff --git a/src/app/shared/auth-nav-menu/user-menu/themed-user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/themed-user-menu.component.ts new file mode 100644 index 0000000000..9dafe6c426 --- /dev/null +++ b/src/app/shared/auth-nav-menu/user-menu/themed-user-menu.component.ts @@ -0,0 +1,33 @@ +import {Component, Input} from '@angular/core'; +import {ThemedComponent} from '../../theme-support/themed.component'; +import {UserMenuComponent} from './user-menu.component'; + +/** + * This component represents the user nav menu. + */ +@Component({ + selector: 'ds-themed-user-menu', + templateUrl: './../../theme-support/themed.component.html', + styleUrls: [] +}) +export class ThemedUserMenuComponent extends ThemedComponent{ + + /** + * The input flag to show user details in navbar expandable menu + */ + @Input() inExpandableNavbar: boolean; + + protected inAndOutputNames: (keyof UserMenuComponent & keyof this)[] = ['inExpandableNavbar']; + + protected getComponentName(): string { + return 'UserMenuComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import((`../../../../themes/${themeName}/app/shared/auth-nav-menu/user-menu/user-menu.component`)); + } + + protected importUnthemedComponent(): Promise { + return import('./user-menu.component'); + } +} diff --git a/src/app/shared/browse-by/shared-browse-by.module.ts b/src/app/shared/browse-by/shared-browse-by.module.ts index ae42576e9b..4041f296c8 100644 --- a/src/app/shared/browse-by/shared-browse-by.module.ts +++ b/src/app/shared/browse-by/shared-browse-by.module.ts @@ -1,15 +1,21 @@ import { NgModule } from '@angular/core'; import { BrowseByComponent } from './browse-by.component'; +import { ThemedBrowseByComponent } from './themed-browse-by.component'; import { CommonModule } from '@angular/common'; import { SharedModule } from '../shared.module'; import { ResultsBackButtonModule } from '../results-back-button/results-back-button.module'; import { BrowseByRoutingModule } from '../../browse-by/browse-by-routing.module'; import { AccessControlRoutingModule } from '../../access-control/access-control-routing.module'; +const DECLARATIONS = [ + BrowseByComponent, + ThemedBrowseByComponent, +]; + @NgModule({ declarations: [ - BrowseByComponent, -], + ...DECLARATIONS, + ], imports: [ ResultsBackButtonModule, BrowseByRoutingModule, @@ -18,8 +24,7 @@ import { AccessControlRoutingModule } from '../../access-control/access-control- SharedModule, ], exports: [ - BrowseByComponent, - SharedModule, + ...DECLARATIONS, ] }) export class SharedBrowseByModule { } diff --git a/src/app/shared/browse-by/themed-browse-by.component.ts b/src/app/shared/browse-by/themed-browse-by.component.ts new file mode 100644 index 0000000000..eaa17ebf16 --- /dev/null +++ b/src/app/shared/browse-by/themed-browse-by.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { ThemedComponent } from '../theme-support/themed.component'; +import { BrowseByComponent } from './browse-by.component'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; +import { StartsWithType } from '../starts-with/starts-with-decorator'; + +/** + * Themed wrapper for {@link BrowseByComponent} + */ +@Component({ + selector: 'ds-themed-browse-by', + styleUrls: [], + templateUrl: '../theme-support/themed.component.html', +}) +export class ThemedBrowseByComponent extends ThemedComponent { + + @Input() title: string; + + @Input() parentname: string; + + @Input() objects$: Observable>>; + + @Input() paginationConfig: PaginationComponentOptions; + + @Input() sortConfig: SortOptions; + + @Input() type: StartsWithType; + + @Input() startsWithOptions: number[]; + + @Input() showPaginator: boolean; + + @Input() hideGear: boolean; + + @Output() prev: EventEmitter = new EventEmitter(); + + @Output() next: EventEmitter = new EventEmitter(); + + @Output() pageSizeChange: EventEmitter = new EventEmitter(); + + @Output() sortDirectionChange: EventEmitter = new EventEmitter(); + + protected inAndOutputNames: (keyof BrowseByComponent & keyof this)[] = [ + 'title', + 'parentname', + 'objects$', + 'paginationConfig', + 'sortConfig', + 'type', + 'startsWithOptions', + 'showPaginator', + 'hideGear', + 'prev', + 'next', + 'pageSizeChange', + 'sortDirectionChange', + ]; + + protected getComponentName(): string { + return 'BrowseByComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/shared/browse-by/browse-by.component.ts`); + } + + protected importUnthemedComponent(): Promise { + return import('./browse-by.component'); + } + +} diff --git a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html index 5d7b092f74..b7b3d344b1 100644 --- a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html @@ -42,7 +42,7 @@ [formModel]="formModel" [displayCancel]="false" (submitForm)="onSubmit()"> - diff --git a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts index bbd8b01257..631a9f0b19 100644 --- a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.ts @@ -127,39 +127,41 @@ export class ComColFormComponent implements On } ngOnInit(): void { - this.formModel.forEach( - (fieldModel: DynamicInputModel) => { - fieldModel.value = this.dso.firstMetadataValue(fieldModel.name); - } - ); - this.formGroup = this.formService.createFormGroup(this.formModel); - - this.updateFieldTranslations(); - this.translate.onLangChange - .subscribe(() => { - this.updateFieldTranslations(); - }); - - if (hasValue(this.dso.id)) { - this.subs.push( - observableCombineLatest([ - this.dsoService.getLogoEndpoint(this.dso.id), - this.dso.logo - ]).subscribe(([href, logoRD]: [string, RemoteData]) => { - this.uploadFilesOptions.url = href; - this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); - // If the object already contains a logo, send out a PUT request instead of POST for setting a new logo - if (hasValue(logoRD.payload)) { - this.uploadFilesOptions.method = RestRequestMethod.PUT; - } - this.initializedUploaderOptions.next(true); - }) + if (hasValue(this.formModel)) { + this.formModel.forEach( + (fieldModel: DynamicInputModel) => { + fieldModel.value = this.dso.firstMetadataValue(fieldModel.name); + } ); - } else { - // Set a placeholder URL to not break the uploader component. This will be replaced once the object is created. - this.uploadFilesOptions.url = 'placeholder'; - this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); - this.initializedUploaderOptions.next(true); + this.formGroup = this.formService.createFormGroup(this.formModel); + + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + + if (hasValue(this.dso.id)) { + this.subs.push( + observableCombineLatest([ + this.dsoService.getLogoEndpoint(this.dso.id), + this.dso.logo + ]).subscribe(([href, logoRD]: [string, RemoteData]) => { + this.uploadFilesOptions.url = href; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + // If the object already contains a logo, send out a PUT request instead of POST for setting a new logo + if (hasValue(logoRD.payload)) { + this.uploadFilesOptions.method = RestRequestMethod.PUT; + } + this.initializedUploaderOptions.next(true); + }) + ); + } else { + // Set a placeholder URL to not break the uploader component. This will be replaced once the object is created. + this.uploadFilesOptions.url = 'placeholder'; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.initializedUploaderOptions.next(true); + } } } diff --git a/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts b/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts index 5bd51ea650..d59030251d 100644 --- a/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts +++ b/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts @@ -3,7 +3,7 @@ import { DSpaceObject } from '../../../../../core/shared/dspace-object.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../../../core/data/remote-data'; import { ActivatedRoute, Router } from '@angular/router'; -import { first, map, take } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../../../../core/shared/operators'; import { hasValue, isEmpty } from '../../../../empty.util'; import { ResourceType } from '../../../../../core/shared/resource-type'; @@ -42,7 +42,7 @@ export class ComcolMetadataComponent imp } ngOnInit(): void { - this.dsoRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); + this.dsoRD$ = this.route.parent.data.pipe(map((data) => data.dso)); } /** diff --git a/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index 48eb9aec96..e4d6c9c8a7 100644 --- a/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -53,7 +53,7 @@ export class EditComColPageComponent implements On this.pages = this.route.routeConfig.children .map((child: any) => child.path) .filter((path: string) => isNotEmpty(path)); // ignore reroutes - this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.dso)); + this.dsoRD$ = this.route.data.pipe(map((data) => data.dso)); } /** diff --git a/src/app/shared/context-help-wrapper/context-help-wrapper.component.html b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html index b031d0f42d..083b8163ff 100644 --- a/src/app/shared/context-help-wrapper/context-help-wrapper.component.html +++ b/src/app/shared/context-help-wrapper/context-help-wrapper.component.html @@ -2,7 +2,7 @@
    - {{elem.text}} + {{elem.text}} {{ elem }} diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index a41b641dec..c818ddc19c 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -22,7 +22,7 @@ export const GOOGLE_ANALYTICS_KLARO_KEY = 'google-analytics'; export const klaroConfiguration: any = { storageName: ANONYMOUS_STORAGE_NAME_KLARO, - privacyPolicy: '/info/privacy', + privacyPolicy: './info/privacy', /* Setting 'hideLearnMore' to 'true' will hide the "learn more / customize" link in diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts index abfe618174..e28a416f23 100644 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.spec.ts @@ -1,6 +1,6 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { MenuServiceStub } from '../testing/menu-service.stub'; -import { of as observableOf } from 'rxjs'; +import { combineLatest, map, of as observableOf } from 'rxjs'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; @@ -16,10 +16,13 @@ import { Item } from '../../core/shared/item.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { MenuID } from '../menu/menu-id.model'; import { MenuItemType } from '../menu/menu-item-type.model'; -import { TextMenuItemModel } from '../menu/menu-item/models/text.model'; import { LinkMenuItemModel } from '../menu/menu-item/models/link.model'; import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Community } from '../../core/shared/community.model'; +import { Collection } from '../../core/shared/collection.model'; +import flatten from 'lodash/flatten'; describe('DSOEditMenuResolver', () => { @@ -37,25 +40,44 @@ describe('DSOEditMenuResolver', () => { let notificationsService; let translate; - const route = { - data: { - menu: { - 'statistics': [{ - id: 'statistics-dummy-1', - active: false, - visible: true, - model: null - }] - } - }, - params: {id: 'test-uuid'}, + const dsoRoute = (dso: DSpaceObject) => { + return { + data: { + menu: { + 'statistics': [{ + id: 'statistics-dummy-1', + active: false, + visible: true, + model: null + }] + } + }, + params: {id: dso.uuid}, + }; }; const state = { url: 'test-url' }; - const testObject = Object.assign(new Item(), {uuid: 'test-uuid', type: 'item', _links: {self: {href: 'self-link'}}}); + const testCommunity: Community = Object.assign(new Community(), { + uuid: 'test-community-uuid', + type: 'community', + _links: {self: {href: 'self-link'}}, + }); + const testCollection: Collection = Object.assign(new Collection(), { + uuid: 'test-collection-uuid', + type: 'collection', + _links: {self: {href: 'self-link'}}, + }); + const testItem: Item = Object.assign(new Item(), { + uuid: 'test-item-uuid', + type: 'item', + _links: {self: {href: 'self-link'}}, + }); + + let testObject: DSpaceObject; + let route; const dummySections1 = [{ id: 'dummy-1', @@ -90,6 +112,10 @@ describe('DSOEditMenuResolver', () => { }]; beforeEach(waitForAsync(() => { + // test with Items unless specified otherwise + testObject = testItem; + route = dsoRoute(testItem); + menuService = new MenuServiceStub(); spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE)); @@ -154,16 +180,17 @@ describe('DSOEditMenuResolver', () => { { ...route.data.menu, [MenuID.DSO_EDIT]: [ - ...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'})), - ...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-uuid'})) + ...dummySections1.map((menu) => Object.assign(menu, {id: menu.id + '-test-item-uuid'})), + ...dummySections2.map((menu) => Object.assign(menu, {id: menu.id + '-test-item-uuid'})) ] } ); - expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-uuid', true, false); + expect(dSpaceObjectDataService.findById).toHaveBeenCalledWith('test-item-uuid', true, false); expect(resolver.getDsoMenus).toHaveBeenCalled(); done(); }); }); + it('should create all menus when a dso is found based on the route scope query param when no id param is present', (done) => { spyOn(resolver, 'getDsoMenus').and.returnValue( [observableOf(dummySections1), observableOf(dummySections2)] @@ -198,6 +225,7 @@ describe('DSOEditMenuResolver', () => { done(); }); }); + it('should return the statistics menu when no dso is found', (done) => { (dSpaceObjectDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); @@ -211,49 +239,165 @@ describe('DSOEditMenuResolver', () => { }); }); }); + describe('getDsoMenus', () => { - it('should return as first part the item version, orcid and claim list ', (done) => { - const result = resolver.getDsoMenus(testObject, route, state); - result[0].subscribe((menuList) => { - expect(menuList.length).toEqual(3); - expect(menuList[0].id).toEqual('orcid-dso'); - expect(menuList[0].active).toEqual(false); - // Visible should be false due to the item not being of type person - expect(menuList[0].visible).toEqual(false); - expect(menuList[0].model.type).toEqual(MenuItemType.LINK); - - expect(menuList[1].id).toEqual('version-dso'); - expect(menuList[1].active).toEqual(false); - expect(menuList[1].visible).toEqual(true); - expect(menuList[1].model.type).toEqual(MenuItemType.ONCLICK); - expect((menuList[1].model as TextMenuItemModel).text).toEqual('message'); - expect(menuList[1].model.disabled).toEqual(false); - expect(menuList[1].icon).toEqual('code-branch'); - - expect(menuList[2].id).toEqual('claim-dso'); - expect(menuList[2].active).toEqual(false); - // Visible should be false due to the item not being of type person - expect(menuList[2].visible).toEqual(false); - expect(menuList[2].model.type).toEqual(MenuItemType.ONCLICK); - expect((menuList[2].model as TextMenuItemModel).text).toEqual('item.page.claim.button'); - done(); + describe('for Communities', () => { + beforeEach(() => { + testObject = testCommunity; + dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCommunity)); + route = dsoRoute(testCommunity); }); + it('should not return Item-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const orcidEntry = menu.find(entry => entry.id === 'orcid-dso'); + expect(orcidEntry).toBeFalsy(); + + const versionEntry = menu.find(entry => entry.id === 'version-dso'); + expect(versionEntry).toBeFalsy(); + + const claimEntry = menu.find(entry => entry.id === 'claim-dso'); + expect(claimEntry).toBeFalsy(); + + done(); + }); + }); + + it('should return Community/Collection-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const subscribeEntry = menu.find(entry => entry.id === 'subscribe'); + expect(subscribeEntry).toBeTruthy(); + expect(subscribeEntry.active).toBeFalse(); + expect(subscribeEntry.visible).toBeTrue(); + expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK); + done(); + }); + }); + + it('should return as third part the common list ', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const editEntry = menu.find(entry => entry.id === 'edit-dso'); + expect(editEntry).toBeTruthy(); + expect(editEntry.active).toBeFalse(); + expect(editEntry.visible).toBeTrue(); + expect(editEntry.model.type).toEqual(MenuItemType.LINK); + expect((editEntry.model as LinkMenuItemModel).link).toEqual( + '/communities/test-community-uuid/edit/metadata' + ); + done(); + }); + }); }); - it('should return as second part the common list ', (done) => { - const result = resolver.getDsoMenus(testObject, route, state); - result[1].subscribe((menuList) => { - expect(menuList.length).toEqual(1); - expect(menuList[0].id).toEqual('edit-dso'); - expect(menuList[0].active).toEqual(false); - expect(menuList[0].visible).toEqual(true); - expect(menuList[0].model.type).toEqual(MenuItemType.LINK); - expect((menuList[0].model as LinkMenuItemModel).text).toEqual('item.page.edit'); - expect((menuList[0].model as LinkMenuItemModel).link).toEqual('/items/test-uuid/edit/metadata'); - expect(menuList[0].icon).toEqual('pencil-alt'); - done(); + + describe('for Collections', () => { + beforeEach(() => { + testObject = testCollection; + dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testCollection)); + route = dsoRoute(testCollection); }); + it('should not return Item-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const orcidEntry = menu.find(entry => entry.id === 'orcid-dso'); + expect(orcidEntry).toBeFalsy(); + + const versionEntry = menu.find(entry => entry.id === 'version-dso'); + expect(versionEntry).toBeFalsy(); + + const claimEntry = menu.find(entry => entry.id === 'claim-dso'); + expect(claimEntry).toBeFalsy(); + + done(); + }); + }); + + it('should return Community/Collection-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const subscribeEntry = menu.find(entry => entry.id === 'subscribe'); + expect(subscribeEntry).toBeTruthy(); + expect(subscribeEntry.active).toBeFalse(); + expect(subscribeEntry.visible).toBeTrue(); + expect(subscribeEntry.model.type).toEqual(MenuItemType.ONCLICK); + done(); + }); + }); + + it('should return as third part the common list ', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const editEntry = menu.find(entry => entry.id === 'edit-dso'); + expect(editEntry).toBeTruthy(); + expect(editEntry.active).toBeFalse(); + expect(editEntry.visible).toBeTrue(); + expect(editEntry.model.type).toEqual(MenuItemType.LINK); + expect((editEntry.model as LinkMenuItemModel).link).toEqual( + '/collections/test-collection-uuid/edit/metadata' + ); + done(); + }); + }); + }); + + describe('for Items', () => { + beforeEach(() => { + testObject = testItem; + dSpaceObjectDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testItem)); + route = dsoRoute(testItem); + }); + + it('should return Item-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const orcidEntry = menu.find(entry => entry.id === 'orcid-dso'); + expect(orcidEntry).toBeTruthy(); + expect(orcidEntry.active).toBeFalse(); + expect(orcidEntry.visible).toBeFalse(); + expect(orcidEntry.model.type).toEqual(MenuItemType.LINK); + + const versionEntry = menu.find(entry => entry.id === 'version-dso'); + expect(versionEntry).toBeTruthy(); + expect(versionEntry.active).toBeFalse(); + expect(versionEntry.visible).toBeTrue(); + expect(versionEntry.model.type).toEqual(MenuItemType.ONCLICK); + expect(versionEntry.model.disabled).toBeFalse(); + + const claimEntry = menu.find(entry => entry.id === 'claim-dso'); + expect(claimEntry).toBeTruthy(); + expect(claimEntry.active).toBeFalse(); + expect(claimEntry.visible).toBeFalse(); + expect(claimEntry.model.type).toEqual(MenuItemType.ONCLICK); + done(); + }); + }); + + it('should not return Community/Collection-specific entries', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const subscribeEntry = menu.find(entry => entry.id === 'subscribe'); + expect(subscribeEntry).toBeFalsy(); + done(); + }); + }); + + it('should return as third part the common list ', (done) => { + const result = resolver.getDsoMenus(testObject, route, state); + combineLatest(result).pipe(map(flatten)).subscribe((menu) => { + const editEntry = menu.find(entry => entry.id === 'edit-dso'); + expect(editEntry).toBeTruthy(); + expect(editEntry.active).toBeFalse(); + expect(editEntry.visible).toBeTrue(); + expect(editEntry.model.type).toEqual(MenuItemType.LINK); + expect((editEntry.model as LinkMenuItemModel).link).toEqual( + '/items/test-item-uuid/edit/metadata' + ); + done(); + }); + }); }); }); }); diff --git a/src/app/shared/dso-page/dso-edit-menu.resolver.ts b/src/app/shared/dso-page/dso-edit-menu.resolver.ts index 749d5580a4..1ade457840 100644 --- a/src/app/shared/dso-page/dso-edit-menu.resolver.ts +++ b/src/app/shared/dso-page/dso-edit-menu.resolver.ts @@ -21,6 +21,9 @@ import { getDSORoute } from '../../app-routing-paths'; import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; import { NotificationsService } from '../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component'; +import { Community } from '../../core/shared/community.model'; +import { Collection } from '../../core/shared/collection.model'; /** * Creates the menus for the dspace object pages @@ -50,27 +53,32 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection if (hasNoValue(id) && hasValue(route.queryParams.scope)) { id = route.queryParams.scope; } - return this.dSpaceObjectDataService.findById(id, true, false).pipe( - getFirstCompletedRemoteData(), - switchMap((dsoRD) => { - if (dsoRD.hasSucceeded) { - const dso = dsoRD.payload; - return combineLatest(this.getDsoMenus(dso, route, state)).pipe( - // Menu sections are retrieved as an array of arrays and flattened into a single array - map((combinedMenus) => [].concat.apply([], combinedMenus)), - map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), - map((menus) => { - return { - ...route.data?.menu, - [MenuID.DSO_EDIT]: menus - }; - }) - ); - } else { - return observableOf({...route.data?.menu}); - } - }) - ); + if (hasNoValue(id)) { + // If there's no ID, we're not on a DSO homepage, so pass on any pre-existing menu route data + return observableOf({ ...route.data?.menu }); + } else { + return this.dSpaceObjectDataService.findById(id, true, false).pipe( + getFirstCompletedRemoteData(), + switchMap((dsoRD) => { + if (dsoRD.hasSucceeded) { + const dso = dsoRD.payload; + return combineLatest(this.getDsoMenus(dso, route, state)).pipe( + // Menu sections are retrieved as an array of arrays and flattened into a single array + map((combinedMenus) => [].concat.apply([], combinedMenus)), + map((menus) => this.addDsoUuidToMenuIDs(menus, dso)), + map((menus) => { + return { + ...route.data?.menu, + [MenuID.DSO_EDIT]: menus + }; + }) + ); + } else { + return observableOf({...route.data?.menu}); + } + }) + ); + } } /** @@ -79,6 +87,7 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection getDsoMenus(dso, route, state): Observable[] { return [ this.getItemMenu(dso), + this.getComColMenu(dso), this.getCommonMenu(dso, state) ]; } @@ -173,6 +182,39 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection } } + /** + * Get Community/Collection-specific menus + */ + protected getComColMenu(dso): Observable { + if (dso instanceof Community || dso instanceof Collection) { + return combineLatest([ + this.authorizationService.isAuthorized(FeatureID.CanSubscribe, dso.self), + ]).pipe( + map(([canSubscribe]) => { + return [ + { + id: 'subscribe', + active: false, + visible: canSubscribe, + model: { + type: MenuItemType.ONCLICK, + text: 'subscriptions.tooltip', + function: () => { + const modalRef = this.modalService.open(SubscriptionModalComponent); + modalRef.componentInstance.dso = dso; + } + } as OnClickMenuItemModel, + icon: 'bell', + index: 4 + }, + ]; + }) + ); + } else { + return observableOf([]); + } + } + /** * Claim a researcher by creating a profile * Shows notifications and/or hides the menu section on success/error diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts index 8e4a7008af..1925099418 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-expandable-menu-section/dso-edit-menu-expandable-section.component.ts @@ -13,7 +13,6 @@ import { hasValue } from '../../../empty.util'; * Represents an expandable section in the dso edit menus */ @Component({ - /* tslint:disable:component-selector */ selector: 'ds-dso-edit-menu-expandable-section', templateUrl: './dso-edit-menu-expandable-section.component.html', styleUrls: ['./dso-edit-menu-expandable-section.component.scss'], diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts index af3381ef71..060049ef5f 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.ts @@ -10,7 +10,6 @@ import { MenuSection } from '../../../menu/menu-section.model'; * Represents a non-expandable section in the dso edit menus */ @Component({ - /* tslint:disable:component-selector */ selector: 'ds-dso-edit-menu-section', templateUrl: './dso-edit-menu-section.component.html', styleUrls: ['./dso-edit-menu-section.component.scss'] diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html deleted file mode 100644 index 15135009fc..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts deleted file mode 100644 index 726854778d..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DsoPageSubscriptionButtonComponent } from './dso-page-subscription-button.component'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { of as observableOf } from 'rxjs'; -import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; -import { Item } from '../../../core/shared/item.model'; -import { ITEM } from '../../../core/shared/item.resource-type'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; - -describe('DsoPageSubscriptionButtonComponent', () => { - let component: DsoPageSubscriptionButtonComponent; - let fixture: ComponentFixture; - let de: DebugElement; - - const authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: jasmine.createSpy('isAuthorized') // observableOf(true) - }); - - const mockItem = Object.assign(new Item(), { - id: 'fake-id', - uuid: 'fake-id', - handle: 'fake/handle', - lastModified: '2018', - type: ITEM, - _links: { - self: { - href: 'https://localhost:8000/items/fake-id' - } - } - }); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - NgbModalModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }) - ], - declarations: [ DsoPageSubscriptionButtonComponent ], - providers: [ - { provide: AuthorizationDataService, useValue: authorizationService }, - ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DsoPageSubscriptionButtonComponent); - component = fixture.componentInstance; - de = fixture.debugElement; - component.dso = mockItem; - }); - - describe('when is authorized', () => { - beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(true)); - fixture.detectChanges(); - }); - - it('should display subscription button', () => { - expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeTruthy(); - }); - }); - - describe('when is not authorized', () => { - beforeEach(() => { - authorizationService.isAuthorized.and.returnValue(observableOf(false)); - fixture.detectChanges(); - }); - - it('should not display subscription button', () => { - expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeNull(); - }); - }); -}); diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts deleted file mode 100644 index 54cd9e6bb0..0000000000 --- a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; - -import { Observable, of } from 'rxjs'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; - -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component'; -import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; - -@Component({ - selector: 'ds-dso-page-subscription-button', - templateUrl: './dso-page-subscription-button.component.html', - styleUrls: ['./dso-page-subscription-button.component.scss'] -}) -/** - * Display a button that opens the modal to manage subscriptions - */ -export class DsoPageSubscriptionButtonComponent implements OnInit { - - /** - * Whether the current user is authorized to edit the DSpaceObject - */ - isAuthorized$: Observable = of(false); - - /** - * Reference to NgbModal - */ - public modalRef: NgbModalRef; - - /** - * DSpaceObject that is being viewed - */ - @Input() dso: DSpaceObject; - - constructor( - protected authorizationService: AuthorizationDataService, - private modalService: NgbModal, - ) { - } - - /** - * check if the current DSpaceObject can be subscribed by the user - */ - ngOnInit(): void { - this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanSubscribe, this.dso.self); - } - - /** - * Open the modal to subscribe to the related DSpaceObject - */ - public openSubscriptionModal() { - this.modalRef = this.modalService.open(SubscriptionModalComponent); - this.modalRef.componentInstance.dso = this.dso; - } - -} diff --git a/src/app/shared/error/error.component.ts b/src/app/shared/error/error.component.ts index 9a6b0660bb..6572598c8b 100644 --- a/src/app/shared/error/error.component.ts +++ b/src/app/shared/error/error.component.ts @@ -3,7 +3,7 @@ import { Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; -import { AlertType } from '../alert/aletr-type'; +import { AlertType } from '../alert/alert-type'; @Component({ selector: 'ds-error', diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index b77ee9950c..355e10b9a0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -169,7 +169,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { metadataFields: [], hasSelectableMetadata: false }), - new DynamicDsDatePickerModel({ id: 'datepicker' }), + new DynamicDsDatePickerModel({ id: 'datepicker', repeatable: false }), new DynamicLookupModel({ id: 'lookup', metadataFields: [], diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts index d5e735ed1a..5f7e2e3e22 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -183,7 +183,8 @@ export class DsDynamicTypeBindRelationService { const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value : (Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value); - const valueChanges = relatedModel.valueChanges.pipe( + const updateSubject = (relatedModel.type === 'CHECKBOX_GROUP' ? relatedModel.valueUpdates : relatedModel.valueChanges); + const valueChanges = updateSubject.pipe( startWith(initValue) ); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html index 1046dd6b2d..26803f3c67 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -1,6 +1,6 @@
    - + {{model.placeholder}} * { let dateFixture: ComponentFixture; let html; + const renderer2: Renderer2 = { + selectRootElement: jasmine.createSpy('selectRootElement'), + querySelector: jasmine.createSpy('querySelector'), + } as unknown as Renderer2; + // waitForAsync beforeEach beforeEach(waitForAsync(() => { @@ -54,7 +61,8 @@ describe('DsDatePickerComponent test suite', () => { ChangeDetectorRef, DsDatePickerComponent, { provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService }, - { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService } + { provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService }, + { provide: Renderer2, useValue: renderer2 }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -233,6 +241,102 @@ describe('DsDatePickerComponent test suite', () => { expect(dateComp.disabledMonth).toBeFalsy(); expect(dateComp.disabledDay).toBeFalsy(); }); + + it('should move focus on month field when on year field and tab pressed', fakeAsync(() => { + const event = { + field: 'day', + value: null + }; + const event1 = { + field: 'month', + value: null + }; + dateComp.onChange(event); + dateComp.onChange(event1); + + const yearElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_year`)); + const monthElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_month`)); + + yearElement.nativeElement.focus(); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(yearElement.nativeElement); + + dateFixture.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'tab' })); + dateFixture.detectChanges(); + + tick(200); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(monthElement.nativeElement); + })); + + it('should move focus on day field when on month field and tab pressed', fakeAsync(() => { + const event = { + field: 'day', + value: null + }; + dateComp.onChange(event); + + const monthElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_month`)); + const dayElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_day`)); + + monthElement.nativeElement.focus(); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(monthElement.nativeElement); + + dateFixture.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'tab' })); + dateFixture.detectChanges(); + + tick(200); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(dayElement.nativeElement); + })); + + it('should move focus on month field when on day field and shift tab pressed', fakeAsync(() => { + const event = { + field: 'day', + value: null + }; + dateComp.onChange(event); + + const monthElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_month`)); + const dayElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_day`)); + + dayElement.nativeElement.focus(); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(dayElement.nativeElement); + + dateFixture.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'shift.tab' })); + dateFixture.detectChanges(); + + tick(200); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(monthElement.nativeElement); + })); + + it('should move focus on year field when on month field and shift tab pressed', fakeAsync(() => { + const yearElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_year`)); + const monthElement = dateFixture.debugElement.query(By.css(`#${dateComp.model.id}_month`)); + + monthElement.nativeElement.focus(); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(monthElement.nativeElement); + + dateFixture.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'shift.tab' })); + dateFixture.detectChanges(); + + tick(200); + dateFixture.detectChanges(); + + expect(document.activeElement).toBe(yearElement.nativeElement); + })); + }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts index 8d5ce5b48e..404e851493 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -1,5 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; +import { Component, EventEmitter, HostListener, Inject, Input, OnInit, Output, Renderer2 } from '@angular/core'; import { DynamicDsDatePickerModel } from './date-picker.model'; import { hasValue } from '../../../../../empty.util'; import { @@ -7,6 +7,11 @@ import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DOCUMENT } from '@angular/common'; +import isEqual from 'lodash/isEqual'; + + +export type DatePickerFieldType = '_year' | '_month' | '_day'; export const DS_DATE_PICKER_SEPARATOR = '-'; @@ -50,8 +55,12 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement disabledMonth = true; disabledDay = true; + private readonly fields: DatePickerFieldType[] = ['_year', '_month', '_day']; + constructor(protected layoutService: DynamicFormLayoutService, - protected validationService: DynamicFormValidationService + protected validationService: DynamicFormValidationService, + private renderer: Renderer2, + @Inject(DOCUMENT) private _document: Document ) { super(layoutService, validationService); } @@ -80,9 +89,8 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement } } - this.maxYear = this.initialYear + 100; - - } + this.maxYear = now.getUTCFullYear() + 100; + } onBlur(event) { this.blur.emit(); @@ -166,6 +174,67 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement this.change.emit(value); } + /** + * Listen to keydown Tab event. + * Get the active element and blur it, in order to focus the next input field. + */ + @HostListener('keydown.tab', ['$event']) + onTabKeydown(event: KeyboardEvent) { + event.preventDefault(); + const activeElement: Element = this._document.activeElement; + (activeElement as any).blur(); + const index = this.selectedFieldIndex(activeElement); + if (index < 0) { + return; + } + let fieldToFocusOn = index + 1; + if (fieldToFocusOn < this.fields.length) { + this.focusInput(this.fields[fieldToFocusOn]); + } + } + + @HostListener('keydown.shift.tab', ['$event']) + onShiftTabKeyDown(event: KeyboardEvent) { + event.preventDefault(); + const activeElement: Element = this._document.activeElement; + (activeElement as any).blur(); + const index = this.selectedFieldIndex(activeElement); + let fieldToFocusOn = index - 1; + if (fieldToFocusOn >= 0) { + this.focusInput(this.fields[fieldToFocusOn]); + } + } + + private selectedFieldIndex(activeElement: Element): number { + return this.fields.findIndex(field => isEqual(activeElement.id, this.model.id.concat(field))); + } + + /** + * Focus the input field for the given type + * based on the model id. + * Used to focus the next input field + * in case of a disabled field. + * @param type DatePickerFieldType + */ + focusInput(type: DatePickerFieldType) { + const field = this._document.getElementById(this.model.id.concat(type)); + if (field) { + + if (hasValue(this.year) && isEqual(type, '_year')) { + this.disabledMonth = true; + this.disabledDay = true; + } + if (hasValue(this.year) && isEqual(type, '_month')) { + this.disabledMonth = false; + } else if (hasValue(this.month) && isEqual(type, '_day')) { + this.disabledDay = false; + } + setTimeout(() => { + this.renderer.selectRootElement(field).focus(); + }, 100); + } + } + onFocus(event) { this.focus.emit(event); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index 5af9b2bd32..88820cdaa3 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -15,6 +15,7 @@ export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; export interface DynamicDsDateControlModelConfig extends DynamicDatePickerModelConfig { legend?: string; typeBindRelations?: DynamicFormControlRelation[]; + repeatable: boolean; } /** @@ -37,7 +38,7 @@ export class DynamicDsDatePickerModel extends DynamicDateControlModel { this.metadataValue = (config as any).metadataValue; this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; this.hiddenUpdates = new BehaviorSubject(this.hidden); - + this.repeatable = config.repeatable; // This was a subscription, then an async setTimeout, but it seems unnecessary const parentModel = this.getRootParent(this); if (parentModel && isNotUndefined(parentModel.hidden)) { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index 1d6037a409..dc7c796648 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -46,6 +46,7 @@ export class DynamicConcatModel extends DynamicFormGroupModel { @serializable() submissionId: string; @serializable() hasSelectableMetadata: boolean; @serializable() metadataValue: MetadataValue; + @serializable() readOnly?: boolean; isCustomGroup = true; valueUpdates: Subject; @@ -65,6 +66,7 @@ export class DynamicConcatModel extends DynamicFormGroupModel { this.valueUpdates = new Subject(); this.valueUpdates.subscribe((value: string) => this.value = value); this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.readOnly = config.disabled; } get value() { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index edbd5710d2..3c6abaa851 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -55,6 +55,7 @@ export class DsDynamicInputModel extends DynamicInputModel { this.metadataFields = config.metadataFields; this.hint = config.hint; this.readOnly = config.readOnly; + this.disabled = config.readOnly; this.value = config.value; this.relationship = config.relationship; this.submissionId = config.submissionId; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index aa62e5b40d..16c46fc26b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -130,7 +130,7 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen (v) => v.value === option.value)); const item: ListItem = { - id: value, + id: `${this.model.id}_${value}`, label: option.display, value: checked, index: key diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index e6b0cf508f..3c19ecda13 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -39,6 +39,7 @@ [ngbTypeahead]="search" [placeholder]="model.placeholder" [readonly]="model.readOnly" + [disabled]="model.readOnly" [resultTemplate]="rt" [type]="model.inputType" [(ngModel)]="currentValue" @@ -63,6 +64,7 @@ [name]="model.name" [placeholder]="model.placeholder" [readonly]="true" + [disabled]="model.readOnly" [type]="model.inputType" [value]="currentValue?.display" (focus)="onFocus($event)" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 251ff36a68..2ff4256404 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -30,8 +30,8 @@ import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/ import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyTreeviewComponent } from '../../../../vocabulary-treeview/vocabulary-treeview.component'; import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyTreeviewModalComponent } from '../../../../vocabulary-treeview-modal/vocabulary-treeview-modal.component'; /** * Component representing a onebox input field. @@ -216,16 +216,19 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple * @param event The click event fired */ openTree(event) { + if (this.model.readOnly) { + return; + } event.preventDefault(); event.stopImmediatePropagation(); this.subs.push(this.vocabulary$.pipe( map((vocabulary: Vocabulary) => vocabulary.preloadLevel), take(1) ).subscribe((preloadLevel) => { - const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewComponent, { size: 'lg', windowClass: 'treeview' }); + const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, { size: 'lg', windowClass: 'treeview' }); modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions; modalRef.componentInstance.preloadLevel = preloadLevel; - modalRef.componentInstance.selectedItem = this.currentValue ? this.currentValue : ''; + modalRef.componentInstance.selectedItems = this.currentValue ? [this.currentValue.value] : []; modalRef.result.then((result: VocabularyEntryDetail) => { if (result) { this.currentValue = result; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 8a4d502287..1ac38e9943 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -3,8 +3,10 @@ role="combobox" [attr.aria-label]="model.label" [attr.aria-owns]="'combobox_' + id + '_listbox'"> - + + + (keydown)="selectOnKeyDown($event, sdRef)">
    -
    +
    diff --git a/src/app/shared/form/form.module.ts b/src/app/shared/form/form.module.ts index de18c53363..792de6f251 100644 --- a/src/app/shared/form/form.module.ts +++ b/src/app/shared/form/form.module.ts @@ -32,7 +32,7 @@ import { NumberPickerComponent } from './number-picker/number-picker.component'; import { AuthorityConfidenceStateDirective } from './directives/authority-confidence-state.directive'; import { SortablejsModule } from 'ngx-sortablejs'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; -import { VocabularyTreeviewService } from './vocabulary-treeview/vocabulary-treeview.service'; +import { VocabularyTreeviewModalComponent } from './vocabulary-treeview-modal/vocabulary-treeview-modal.component'; import { FormBuilderService } from './builder/form-builder.service'; import { DsDynamicTypeBindRelationService } from './builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { FormService } from './form.service'; @@ -71,7 +71,8 @@ const COMPONENTS = [ ChipsComponent, NumberPickerComponent, VocabularyTreeviewComponent, - ThemedExternalSourceEntryImportModalComponent + VocabularyTreeviewModalComponent, + ThemedExternalSourceEntryImportModalComponent, ]; const DIRECTIVES = [ @@ -105,7 +106,6 @@ const DIRECTIVES = [ provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, - VocabularyTreeviewService, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, diff --git a/src/app/shared/form/number-picker/number-picker.component.ts b/src/app/shared/form/number-picker/number-picker.component.ts index 82240c41d1..40562dd61c 100644 --- a/src/app/shared/form/number-picker/number-picker.component.ts +++ b/src/app/shared/form/number-picker/number-picker.component.ts @@ -103,13 +103,12 @@ export class NumberPickerComponent implements OnInit, ControlValueAccessor { if (i >= this.min && i <= this.max) { this.value = i; - this.emitChange(); } else if (event.target.value === null || event.target.value === '') { this.value = null; - this.emitChange(); } else { this.value = undefined; } + this.emitChange(); } catch (e) { this.value = undefined; } diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html new file mode 100644 index 0000000000..71eb8e1476 --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.html @@ -0,0 +1,16 @@ + + diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.scss b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts new file mode 100644 index 0000000000..590c69a159 --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VocabularyTreeviewModalComponent } from './vocabulary-treeview-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('VocabularyTreeviewModalComponent', () => { + let component: VocabularyTreeviewModalComponent; + let fixture: ComponentFixture; + + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ VocabularyTreeviewModalComponent ], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VocabularyTreeviewModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts new file mode 100644 index 0000000000..c6b0bf20fe --- /dev/null +++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; + +@Component({ + selector: 'ds-vocabulary-treeview-modal', + templateUrl: './vocabulary-treeview-modal.component.html', + styleUrls: ['./vocabulary-treeview-modal.component.scss'] +}) +/** + * Component that contains a modal to display a VocabularyTreeviewComponent + */ +export class VocabularyTreeviewModalComponent { + + /** + * The {@link VocabularyOptions} object + */ + @Input() vocabularyOptions: VocabularyOptions; + + /** + * Representing how many tree level load at initialization + */ + @Input() preloadLevel = 2; + + /** + * The vocabulary entries already selected, if any + */ + @Input() selectedItems: string[] = []; + + /** + * Whether to allow selecting multiple values with checkboxes + */ + @Input() multiSelect = false; + + /** + * Initialize instance variables + * + * @param {NgbActiveModal} activeModal + */ + constructor( + public activeModal: NgbActiveModal, + ) { } + + /** + * Method called on entry select + */ + onSelect(item: VocabularyEntryDetail) { + this.activeModal.close(item); + } +} diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts index c167328cab..4ac1b08425 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview-node.model.ts @@ -21,7 +21,8 @@ export class TreeviewNode { public pageInfo: PageInfo = new PageInfo(), public loadMoreParentItem: VocabularyEntryDetail | null = null, public isSearchNode = false, - public isInInitValueHierarchy = false) { + public isInInitValueHierarchy = false, + public isSelected = false) { } updatePageInfo(pageInfo: PageInfo) { @@ -38,7 +39,8 @@ export class TreeviewFlatNode { public pageInfo: PageInfo = new PageInfo(), public loadMoreParentItem: VocabularyEntryDetail | null = null, public isSearchNode = false, - public isInInitValueHierarchy = false) { + public isInInitValueHierarchy = false, + public isSelected = false) { } } diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html index 39c62d6e53..db3dc31948 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html @@ -1,77 +1,101 @@ - -