diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c637079991..054e51d20aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,29 @@ name: Build # Run this Build for all pushes / PRs to current branch -on: [push, pull_request] +on: + push: + # Don't run if PR is only updating static documentation or IDE/editor rules + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.vscode/*' + - 'docs/*' + - 'LICENSE' + - 'LICENSES_THIRD_PARTY' + - 'NOTICE' + - '**/*.md' + pull_request: + # Don't run if PR is only updating static documentation or IDE/editor rules + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.vscode/*' + - 'docs/*' + - 'LICENSE' + - 'LICENSES_THIRD_PARTY' + - 'NOTICE' + - '**/*.md' permissions: contents: read # to fetch code (actions/checkout) @@ -24,6 +46,7 @@ jobs: # Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+ DSPACE_UI_HOST: 127.0.0.1 DSPACE_UI_PORT: 4000 + DSPACE_UI_BASEURL: http://127.0.0.1:4000 # Ensure all SSR caching is disabled in test environment DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0 DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0 @@ -38,10 +61,12 @@ jobs: NODE_OPTIONS: '--max-old-space-size=4096' # Project name to use when running "docker compose" prior to e2e tests COMPOSE_PROJECT_NAME: 'ci' - # Docker images: uses upstream DSpace images from DockerHub (docker.io/dspace/*). - # Upstream CI uses GHCR (ghcr.io) which requires authentication via GITHUB_TOKEN. - # This fork uses the default DockerHub registry instead (no auth needed). - # DOCKER_REGISTRY: ghcr.io + # Docker Registry to use for Docker compose scripts below. + # On the upstream dspace/dspace-angular repository we use GitHub's Container Registry + # (ghcr.io) to avoid aggressive rate limits at DockerHub. Forks cannot authenticate + # against ghcr.io/dspace/* with their own GITHUB_TOKEN (and the images there require + # auth), so on forks we fall back to docker.io where the same images are public. + DOCKER_REGISTRY: ${{ github.repository == 'dspace/dspace-angular' && 'ghcr.io' || 'docker.io' }} strategy: # Create a matrix of Node versions to test against (in parallel) matrix: @@ -52,11 +77,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v7 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} @@ -81,7 +106,7 @@ jobs: id: yarn-cache-dir-path run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Cache Yarn dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -113,18 +138,26 @@ jobs: # Upload code coverage report to artifact (for one version of Node only), # so that it can be shared with the 'codecov' job (see below) # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # NOTE: Coverage is disabled in CI due to Chrome OOM, so this step will be skipped - name: Upload code coverage report to Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: matrix.node-version == '18.x' with: name: coverage-report-${{ matrix.node-version }} path: 'coverage/dspace-angular/lcov.info' - if-no-files-found: ignore retention-days: 14 - # DataShare fork: DockerHub images are public, no login needed. - # Upstream uses GHCR login here, but those images are private to the DSpace org. + # Login to our Docker registry, so that we can access Docker images using "docker compose" below. + # This login is required on the upstream repository because DOCKER_REGISTRY is set to ghcr.io + # and pulling ghcr.io/dspace/* requires authentication. On forks, DOCKER_REGISTRY falls back to + # docker.io (see env block above) where the same images are publicly pullable without a login, + # and forks cannot authenticate against ghcr.io/dspace/* with their own GITHUB_TOKEN anyway. + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v4 + if: github.repository == 'dspace/dspace-angular' + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} # Using "docker compose" start backend using CI configuration # and load assetstore from a cached copy @@ -138,7 +171,7 @@ jobs: # https://github.com/cypress-io/github-action # (NOTE: to run these e2e tests locally, just use 'ng e2e') - name: Run e2e tests (integration tests) - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@v7.4.4 with: # Run tests in Chrome, headless mode (default) browser: chrome @@ -153,7 +186,7 @@ jobs: # Cypress always creates a video of all e2e tests (whether they succeeded or failed) # Save those in an Artifact - name: Upload e2e test videos to Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: e2e-test-videos-${{ matrix.node-version }} @@ -162,7 +195,7 @@ jobs: # If e2e tests fail, Cypress creates a screenshot of what happened # Save those in an Artifact - name: Upload e2e test failure screenshots to Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: e2e-test-screenshots-${{ matrix.node-version }} @@ -187,14 +220,14 @@ jobs: done echo "App started successfully." - # Get homepage and verify that the tag includes "DataShare". + # Get homepage and verify that the tag includes "DSpace". # If it does, then SSR is working, as this tag is created by our MetadataService. # This step also prints entire HTML of homepage for easier debugging if grep fails. - name: Verify SSR (server-side rendering) on Homepage run: | result=$(wget -O- -q http://127.0.0.1:4000/home) echo "$result" - echo "$result" | grep -oE "]*>" | grep DataShare + echo "$result" | grep -oE "]*>" | grep DSpace # Get a specific community in our test data and verify that the "

" tag includes "Publications" (the community name). # If it does, then SSR is working. @@ -311,14 +344,17 @@ jobs: codecov: # Must run after 'tests' job above needs: tests + # Only run on the upstream repository: forks do not have the CODECOV_TOKEN secret, + # and Codecov refuses to create a commit on a protected branch without a token. + if: github.repository == 'dspace/dspace-angular' runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Download artifacts from previous 'tests' job - name: Download coverage artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 # Now attempt upload to Codecov using its action. # NOTE: We use a retry action to retry the Codecov upload if it fails the first time. @@ -326,13 +362,13 @@ jobs: # Retry action: https://github.com/marketplace/actions/retry-action # Codecov action: https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io - uses: Wandalen/wretry.action@v1.3.0 + uses: Wandalen/wretry.action@v3.8.0 with: action: codecov/codecov-action@v4 - # NOTE: fail_ci_if_error is false because coverage is currently disabled (Chrome OOM). - # Change to true once coverage is re-enabled. + # Ensure codecov-action throws an error when it fails to upload + # This allows us to auto-restart the action if an error is thrown with: | - fail_ci_if_error: false + fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # Try re-running action 5 times max attempt_limit: 5 diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index 65cffdfcd9f..7185ac8b7b4 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -15,10 +15,16 @@ on: branches: - main - 'dspace-**' - # Don't run if PR is only updating static documentation + # Don't run if PR is only updating static documentation or IDE/editor rules paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.vscode/*' + - 'docs/*' + - 'LICENSE' + - 'LICENSES_THIRD_PARTY' + - 'NOTICE' - '**/*.md' - - '**/*.txt' schedule: - cron: "37 0 * * 1" @@ -35,19 +41,19 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: javascript # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # Perform GitHub Code Scanning. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bae8c013005..dd73a07ca31 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,7 +13,27 @@ on: - 'dspace-**' tags: - 'dspace-**' + # Don't run if PR is only updating static documentation or IDE/editor rules + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.vscode/*' + - 'docs/*' + - 'LICENSE' + - 'LICENSES_THIRD_PARTY' + - 'NOTICE' + - '**/*.md' pull_request: + # Don't run if PR is only updating static documentation or IDE/editor rules + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.vscode/*' + - 'docs/*' + - 'LICENSE' + - 'LICENSES_THIRD_PARTY' + - 'NOTICE' + - '**/*.md' permissions: contents: read # to fetch code (actions/checkout) @@ -57,4 +77,123 @@ jobs: # Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of # these sites as specified in reusable-docker-build.xml REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }} - REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }} \ No newline at end of file + REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }} + + ################################################################################# + # Test Deployment via Docker to ensure newly built images are working properly + ################################################################################# + docker-deploy: + # 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 + # Must run after all major images are built + needs: [dspace-angular, dspace-angular-dist] + env: + # Override default dspace.server.url & REST 'host' because backend starts at http://127.0.0.1:8080 + dspace__P__server__P__url: http://127.0.0.1:8080/server + DSPACE_REST_HOST: 127.0.0.1 + # Override default dspace.ui.url to also use 127.0.0.1. + dspace__P__ui__P__url: http://127.0.0.1:4000 + # Override default ui.baseUrl to also use 127.0.0.1. This should match 'dspace.ui.url'. + DSPACE_UI_BASEURL: http://127.0.0.1:4000 + steps: + # Checkout our codebase (to get access to Docker Compose scripts) + - name: Checkout codebase + uses: actions/checkout@v7 + # Download Docker image artifacts (which were just built by reusable-docker-build.yml) + - name: Download Docker image artifacts + uses: actions/download-artifact@v8 + with: + # Download all amd64 Docker images (TAR files) into the /tmp/docker directory + pattern: docker-image-*-linux-amd64 + path: /tmp/docker + merge-multiple: true + # Load each of the images into Docker by calling "docker image load" for each. + # This ensures we are using the images just built & not any prior versions on DockerHub + - name: Load all downloaded Docker images + run: | + find /tmp/docker -type f -name "*.tar" -exec docker image load --input "{}" \; + docker image ls -a + # Start backend using our compose script in the codebase. + - name: Start backend in Docker + # MUST use docker.io as we don't have a copy of this backend image in our GitHub Action, + # and docker.io is the only public image. If we ever hit aggressive rate limits at DockerHub, + # we may need to consider making the 'ghcr.io' images public & switch this to 'ghcr.io' + env: + DOCKER_REGISTRY: docker.io + run: | + docker compose -f docker/docker-compose-rest.yml up -d + sleep 10 + docker container ls + # Create a test admin account. Load test data from a simple set of AIPs as defined in cli.ingest.yml + # NOTE: Before creating test data, we wait for the backend to become responsive by requesting it every 10 sec. + # Timeout after 5 minutes. This is done to ensure the backend is fully initialized before we create test data. + - name: Load test data into Backend + run: | + timeout 5m wget --retry-connrefused -t 0 --waitretry=10 http://127.0.0.1:8080/server/api + docker compose -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en + docker compose -f docker/cli.yml -f docker/cli.ingest.yml run --rm dspace-cli + # Verify backend started successfully. + # 1. Make sure root endpoint is responding (check for dspace.name defined in docker-compose.yml) + # 2. Also check /collections endpoint to ensure the test data loaded properly (check for a collection name in AIPs) + - name: Verify backend is responding properly + run: | + result=$(wget -O- -q http://127.0.0.1:8080/server/api) + echo "$result" + echo "$result" | grep -oE "\"DSpace Started with Docker Compose\"" + result=$(wget -O- -q http://127.0.0.1:8080/server/api/core/collections) + echo "$result" + echo "$result" | grep -oE "\"Dog in Yard\"" + # Start production frontend using our compose script in the codebase. + - name: Start production frontend in Docker + # Specify the GHCR copy of the production frontend, so that we use the newly built image + env: + DOCKER_REGISTRY: ghcr.io + run: | + docker compose -f docker/docker-compose-dist.yml up -d + sleep 10 + docker container ls + # Verify production frontend started successfully. + # 1. Make sure /home path has "DSpace software" (this is in the footer of the page) + # 2. Also check /community-list page lists one of the test Communities in the loaded test data + - name: Verify production frontend is responding properly + run: | + result=$(wget -O- -q http://127.0.0.1:4000/home) + echo "$result" + echo "$result" | grep -oE "\"DSpace software\"" + - name: Error logs of production frontend (if error in startup) + if: ${{ failure() }} + run: | + docker compose -f docker/docker-compose-dist.yml logs + # Now shutdown the production frontend image and startup the development frontend image + - name: Shutdown production frontend + run: | + docker compose -f docker/docker-compose-dist.yml down + sleep 10 + docker container ls + - name: Startup development frontend + # Specify the GHCR copy of the development frontend, so that we use the newly built image + env: + DOCKER_REGISTRY: ghcr.io + run: | + docker compose -f docker/docker-compose.yml up -d + sleep 10 + docker container ls + # Verify development frontend started successfully. + # 1. First, keep requesting the frontend every 10 seconds to wait until its up. Timeout after 10 minutes. + # 2. Once it's responding, check to see if the word "DSpace" appears. + # We cannot check for anything more specific because development mode doesn't have SSR. + - name: Verify development frontend is responding properly + run: | + timeout 10m wget --retry-connrefused -t 0 --waitretry=10 http://127.0.0.1:4000 + result=$(wget -O- -q http://127.0.0.1:4000) + echo "$result" + echo "$result" | grep -oE "DSpace" + - name: Error logs of development frontend (if error in startup) + if: ${{ failure() }} + run: | + docker compose -f docker/docker-compose.yml logs + # Shutdown our containers + - name: Shutdown running Docker containers + run: | + docker compose -f docker/docker-compose.yml -f docker/docker-compose-rest.yml down \ No newline at end of file diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index 0a35a6a9504..99dd91c74e3 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -16,7 +16,7 @@ jobs: # Only add to project board if issue is flagged as "needs triage" or has no labels # NOTE: By default we flag new issues as "needs triage" in our issue template if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') - uses: actions/add-to-project@v1.0.0 + uses: actions/add-to-project@v2 # Note, the authentication token below is an ORG level Secret. # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token diff --git a/.github/workflows/port_merged_pull_request.yml b/.github/workflows/port_merged_pull_request.yml index 857f22755e4..1eb4e52b762 100644 --- a/.github/workflows/port_merged_pull_request.yml +++ b/.github/workflows/port_merged_pull_request.yml @@ -18,16 +18,26 @@ permissions: jobs: port_pr: - runs-on: ubuntu-latest - # Don't run on closed *unmerged* pull requests - if: github.event.pull_request.merged + runs-on: ubuntu-slim + # Only run on MERGED pull requests, + # and only run if the merged PR has a label that starts with "port to" + if: > + github.event.pull_request.merged && + contains(toJSON(github.event.pull_request.labels.*.name), '"port to ') steps: # Checkout code - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + # When using 'pull_request_target' (see above), this 'checkout' step will always fail because it + # may make the codebase vulnerable to "pwn requests" via the user-created PR. + # See https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target + # However, we are allowing this unsafe checkout because (1) it's required to backport the PR, and + # (2) this job only runs when a PR is MERGED, and merger only happens after a thorough review of the PR. + allow-unsafe-pr-checkout: true # Port PR to other branch (ONLY if labeled with "port to") # See https://github.com/korthout/backport-action - name: Create backport pull requests - uses: korthout/backport-action@v2 + uses: korthout/backport-action@v4.6.0 with: # Trigger based on a "port to [branch]" label on PR # (This label must specify the branch name to port to) diff --git a/.github/workflows/pull_request_opened.yml b/.github/workflows/pull_request_opened.yml index bbac52af243..1e17cedcd17 100644 --- a/.github/workflows/pull_request_opened.yml +++ b/.github/workflows/pull_request_opened.yml @@ -21,4 +21,4 @@ jobs: # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards # See https://github.com/toshimaru/auto-author-assign - name: Assign PR to creator - uses: toshimaru/auto-author-assign@v2.1.0 + uses: toshimaru/auto-author-assign@v3.1.0 diff --git a/Dockerfile b/Dockerfile index e395e4b90e2..5a2ce5c5f2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,36 @@ # This image will be published as dspace/dspace-angular # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details -FROM docker.io/node:18-alpine +FROM docker.io/node:22-alpine # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* WORKDIR /app -ADD . /app/ -EXPOSE 4000 + +# Copy over package files first, so this layer will only be rebuilt if those files change. +COPY package.json yarn.lock ./ # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com # See, for example https://github.com/yarnpkg/yarn/issues/5540 RUN yarn install --network-timeout 300000 +# Add the rest of the source code +COPY . /app/ + # When running in dev mode, 4GB of memory is required to build & launch the app. # This default setting can be overridden as needed in your shell, via an env file or in docker-compose. # See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/ ENV NODE_OPTIONS="--max_old_space_size=4096" # On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc). -# Listen / accept connections from all IP addresses. -# NOTE: At this time it is only possible to run Docker container in Production mode -# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485 ENV NODE_ENV=development -CMD yarn serve --host 0.0.0.0 + +EXPOSE 4000 + +# On startup, run this command to start application in dev mode +ENTRYPOINT [ "yarn", "serve" ] +# By default set host to 0.0.0.0 to listen/accept connections from all IP addresses. +# Poll for changes every 5 seconds (if any detected, app will rebuild/restart) +CMD ["--host 0.0.0.0", "--poll 5000"] diff --git a/Dockerfile.dist b/Dockerfile.dist index be72de4afc4..749f4e81e9d 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -4,28 +4,46 @@ # Test build: # docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-8_x-dist . -FROM docker.io/node:18-alpine AS build +# Step 1 - Build code for production +FROM docker.io/node:22-alpine AS build # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* WORKDIR /app + +# Copy over package files first, so this layer will only be rebuilt if those files change. COPY package.json yarn.lock ./ RUN yarn install --network-timeout 300000 -ADD . /app/ +# Around 4GB of memory is required to build the app for production. +# This default setting can be overridden as needed in your shell, via an env file or in docker-compose. +# See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/ +ENV NODE_OPTIONS="--max_old_space_size=4096" + +COPY . /app/ RUN yarn build:prod -FROM node:18-alpine +# Step 2 - Start up UI via PM2 +FROM docker.io/node:22-alpine + +# Install PM2 RUN npm install --global pm2 +# Copy pre-built code from build image COPY --chown=node:node --from=build /app/dist /app/dist +# Copy configs and PM2 startup script from local machine COPY --chown=node:node config /app/config COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json +# Start up UI in PM2 in production mode WORKDIR /app USER node ENV NODE_ENV=production EXPOSE 4000 -CMD pm2-runtime start dspace-ui.json --json + +# On startup, run start the DSpace UI in PM2 +ENTRYPOINT [ "pm2-runtime", "start", "dspace-ui.json" ] +# By default, pass param that specifies to use JSON format logs. +CMD ["--json"] \ No newline at end of file diff --git a/README.md b/README.md index fe2af85aa40..0cc293434d1 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ If you run into odd test errors, see the Angular guide to debugging tests: https E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Configuration for cypress can be found in the `cypress.json` file in the root directory. -The test files can be found in the `./cypress/integration/` folder. +The test files can be found in the `./cypress/e2e/` 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/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. @@ -313,7 +313,7 @@ The `ng e2e` command will start Cypress and allow you to select the browser you #### Writing E2E Tests -All E2E tests must be created under the `./cypress/integration/` folder, and must end in `.spec.ts`. Subfolders are allowed. +All E2E tests must be created under the `./cypress/e2e/` folder, and must end in `.spec.ts`. Subfolders are allowed. * The easiest way to start creating new tests is by running `ng e2e`. This builds the app and brings up Cypress. * From here, if you are editing an existing test file, you can either open it in your IDE or run it first to see what it already does. @@ -392,9 +392,9 @@ dspace-angular ├── config * │ └── config.yml * Default app config ├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests -│ ├── downloads * -│ ├── fixtures * Folder for e2e/integration test files -│ ├── integration * Folder for any fixtures needed by e2e tests +│ ├── downloads * (Optional) Folder for files downloaded during e2e tests +│ ├── e2e * Folder for e2e/integration test files +│ ├── fixtures * Folder for reusable static test data (JSON, images, etc.) │ ├── plugins * Folder for Cypress plugins (if any) │ ├── support * Folder for global e2e test actions/commands (run for all tests) │ └── tsconfig.json * TypeScript configuration file for e2e tests diff --git a/config/config.example.yml b/config/config.example.yml index 154b0dea47a..c02e5ccfaea 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -8,6 +8,9 @@ ui: ssl: false host: localhost port: 4000 + # Specify the public URL that this user interface responds to. This corresponds to the "dspace.ui.url" property in your backend's local.cfg. + # The baseUrl is used for redirects and SEO links (in robots.txt). + baseUrl: http://localhost:4000 # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: / # The rateLimiter settings limit each IP to a 'max' of 500 requests per 'windowMs' (1 minute). @@ -24,8 +27,11 @@ ssr: # disabled (false) by default to boost server performance at the expense of loading smoothness. inlineCriticalCss: false # Patterns to be run as regexes against the path of the page to check if SSR is allowed. - # If the path match any of the regexes it will be served directly in CSR. + # If the path match any of the regexes it will be served directly in CSR (i.e. SSR will be disabled for this path). # By default, excludes community and collection browse, global browse, global search, community list, statistics and various administrative tools. + # [WARNING] changing these settings may impact your Search Engine Optimization (SEO). Any paths listed in "excludedPathPatterns" + # may NOT be indexable by search engine crawlers (like Google Scholar). For search engine crawlers to fully index your site, + # this "excludePathPatterns" should NEVER exclude SSR from the homepage, community/collection pages or item/entity pages. excludePathPatterns: - pattern: "^/communities/[a-f0-9-]{36}/browse(/.*)?$" flag: "i" @@ -87,6 +93,9 @@ cache: # NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because # all compiled *.js files include a unique hash in their name which updates when content is modified. control: max-age=604800 # revalidate browser + # These static files should not be cached (paths relative to dist/browser, including the leading slash) + noCacheFiles: + - '/index.html' autoSync: defaultTime: 0 maxBufferSize: 100 @@ -427,6 +436,7 @@ themes: # - name: BASE_THEME_NAME # - name: dspace + prefetch: true headTags: - tagName: link attributes: diff --git a/cypress.config.ts b/cypress.config.ts index 36d8120342a..a88274e6c88 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -36,7 +36,9 @@ export default defineConfig({ DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', // Administrator users group - DSPACE_ADMINISTRATOR_GROUP: 'e59f5659-bff9-451e-b28f-439e7bd467e4' + DSPACE_ADMINISTRATOR_GROUP: 'e59f5659-bff9-451e-b28f-439e7bd467e4', + //Collection to send and test workflow item + DSPACE_TEST_SUBMIT_WORKFLOW_COLLECTION_NAME: '1-step Workflow collection', }, e2e: { // Setup our plugins for e2e tests diff --git a/cypress/e2e/community-list.cy.ts b/cypress/e2e/community-list.cy.ts index 9b9c87b112d..be54ddb0fce 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -2,6 +2,86 @@ import { testA11y } from 'cypress/support/utils'; describe('Community List Page', () => { + function validateHierarchyLevel(currentLevel = 1): void { + // Find all elements with the current aria-level + cy.get(`ds-community-list cdk-tree-node.expandable-node[aria-level="${currentLevel}"]`).should('exist').then(($nodes) => { + let sublevelExists = false; + cy.wrap($nodes).each(($node) => { + // Check if the current node has an expand button and click it + if ($node.find('[data-test="expand-button"]').length) { + sublevelExists = true; + cy.wrap($node).find('[data-test="expand-button"]').click(); + } + }).then(() => { + // After expanding all buttons, validate if a sublevel exists + if (sublevelExists) { + const nextLevelSelector = `ds-community-list cdk-tree-node.expandable-node[aria-level="${currentLevel + 1}"]`; + cy.get(nextLevelSelector).then(($nextLevel) => { + if ($nextLevel.length) { + // Recursively validate the next level + validateHierarchyLevel(currentLevel + 1); + } + }); + } + }); + }); + } + + beforeEach(() => { + cy.visit('/community-list'); + + // tag must be loaded + cy.get('ds-community-list-page').should('be.visible'); + + // tag must be loaded + cy.get('ds-community-list').should('be.visible'); + }); + + it('should expand community/collection hierarchy', () => { + // Execute Hierarchy levels validation recursively + validateHierarchyLevel(1); + }); + + it('should display community/collections name with item count', () => { + // Open every + cy.get('[data-test="expand-button"]').click({ multiple: true }); + cy.wait(300); + + // A first must be found and validate that tag (community name) and tag (item count) exists in it + cy.get('ds-community-list').find('cdk-tree-node.expandable-node').then(($nodes) => { + cy.wrap($nodes).each(($node) => { + cy.wrap($node).find('a').should('exist'); + cy.wrap($node).find('span').should('exist'); + }); + }); + }); + + it('should enable "show more" button when 20 top-communities or more are presents', () => { + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').then(($nodes) => { + //Validate that there are 20 or more top-community elements + if ($nodes.length >= 20) { + //Validate that "show more" button is visible and then click on it + cy.get('[data-test="show-more-button"]').should('be.visible'); + } else { + cy.get('[data-test="show-more-button"]').should('not.exist'); + } + }); + }); + + it('should show 21 or more top-communities if click "show more" button', () => { + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').then(($nodes) => { + //Validate that there are 20 or more top-community elements + if ($nodes.length >= 20) { + //Validate that "show more" button is visible and then click on it + cy.get('[data-test="show-more-button"]').click(); + cy.wait(300); + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').should('have.length.at.least', 21); + } else { + cy.get('[data-test="show-more-button"]').should('not.exist'); + } + }); + }); + it('should pass accessibility tests', () => { cy.visit('/community-list'); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index 471fa3a73d8..4deab547a02 100644 --- a/cypress/e2e/item-edit.cy.ts +++ b/cypress/e2e/item-edit.cy.ts @@ -23,9 +23,6 @@ describe('Edit Item > Edit Metadata tab', () => { // tag must be loaded cy.get('ds-edit-item-page').should('be.visible'); - // Wait for any loading spinners to disappear - cy.get('ds-edit-item-page ds-loading').should('not.exist'); - // wait for all the tabs to be rendered on this page cy.get('ds-edit-item-page ul[role="tablist"]').each(($row: HTMLUListElement) => { cy.wrap($row).find('a[role="tab"]').should('be.visible'); @@ -37,16 +34,16 @@ describe('Edit Item > Edit Metadata tab', () => { }); // Analyze for accessibility issues - testA11y('ds-edit-item-page', { - rules: { - // Edit-metadata uses nested role="table" wrappers (per-field value lists) which - // briefly contain no rows while values are hydrating, causing a flaky - // "aria-required-children" violation. Same ng-bootstrap / nested-table - // limitation as DSpace issue #2216 (see health-page.cy.ts which waives - // this rule for the same reason). - 'aria-required-children': { enabled: false }, - }, - } as Options); + testA11y('ds-edit-item-page', + { + rules: { + // Disable flakey "aria-required-children" test. While this test passes when run locally, + // in GitHub CI it will return random failures roughly 1/3 of the time saying that the + // "tablist" doesn't contain required "tab" elements, even though they do exist. + 'aria-required-children': { enabled: false }, + }, + } as Options, + ); }); }); @@ -90,7 +87,6 @@ describe('Edit Item > Bitstreams tab', () => { cy.get('ds-edit-item-page ul[role="tablist"]').each(($row: HTMLUListElement) => { cy.wrap($row).find('a[role="tab"]').should('be.visible'); }); - // Table of item bitstreams must also be loaded cy.get('div.item-bitstreams').should('be.visible'); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 159bb4f5e65..c6ffbc54a74 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -131,4 +131,157 @@ describe('My DSpace page', () => { testA11y('ds-submission-import-external'); }); + it('should let you filter to only archived items', () => { + cy.visit('/mydspace'); + + //To wait filter be ready + cy.intercept({ + method: 'GET', + url: '/server/api/discover/facets/namedresourcetype**', + }).as('facetNamedResourceType'); + + //This page is restricted, so we will be shown the login form. Fill it in and submit it + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + + //Wait for the page to display + cy.wait('@facetNamedResourceType'); + + //Open all filters + cy.get('.filter-toggle').click({ multiple: true }); + + //The authority filter should be visible. + cy.get('ds-search-authority-filter').scrollIntoView().should('be.visible'); + + //Intercept the request to filter and the request of filter. + cy.intercept({ + method: 'GET', + url: '/server/api/discover/search/objects**', + }).as('filterByItem'); + + //Apply the filter to the “archived” items. + cy.get('ds-search-authority-filter a[href*="f.namedresourcetype=item,authority"]').find('input[type="checkbox"]').click(); + + //Wait for the response. + cy.wait('@filterByItem'); + + //Check that we have at least one item and that they all have the archived badge. + cy.get('ds-item-search-result-list-element-submission').should('exist'); + cy.get('ds-item-search-result-list-element-submission') + .each(($item) => { + cy.wrap($item) + .find('.badge-archived') + .should('exist'); + }); + }); + + //This test also generate an item to validate workflow task section + it('should upload a file via drag & drop, display it in the UI and submit the item', () => { + const fileName = 'example.pdf'; + const currentYear = new Date().getFullYear(); + + cy.visit('/mydspace'); + + //This page is restricted, so we will be shown the login form. Fill it in and submit it + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); + + //Wait for the page to display + cy.get('ds-my-dspace-page').should('be.visible'); + + cy.wait(500); + + //Select the uploader and perform the drag-and-drop action. + cy.get('ds-uploader .well').selectFile(`cypress/fixtures/${fileName}`, { action: 'drag-drop' }); + + //Validate that the file appears in the UI + cy.get('ds-uploader .filename').should('exist').and('contain.text', fileName); + + //Validate that there is now exactly 1 file in the queue + cy.get('ds-uploader .upload-item-top').should('have.length', 1); + + // This should display the (popup window) + cy.get('ds-collection-dropdown').should('be.visible'); + + // Type in a known Collection name in the search box + cy.get('ds-collection-dropdown input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_WORKFLOW_COLLECTION_NAME')); + + // Click on the button matching that known Collection name + cy.get('ds-collection-dropdown li[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_WORKFLOW_COLLECTION_NAME')).concat('"]')).click(); + + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + //Fill required fields + cy.get('ds-submission-section-container ngb-accordion').first() + .find('button').click(); + cy.get('#dc_title').should('be.visible').type('Workflow test item'); + cy.get('#dc_date_issued_year').type(currentYear.toString()); + cy.get('input[name="dc.type"]').click(); + cy.get('.dropdown-menu').should('be.visible').contains('button', 'Animation').click(); + cy.get('ds-submission-section-container ngb-accordion').last() + .find('button').click(); + cy.get('#granted').check({ force: true }); + + //Press deposit button + cy.get('button[data-test="deposit"]').click(); + + //validate that URL is /mydspace + cy.url().should('include', '/mydspace'); + + }); + + it('should let you take task from workflow', () => { + cy.visit('/mydspace'); + + //This page is restricted, so we will be shown the login form. Fill it in and submit it + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + + //Wait for the page to display + cy.get('ds-my-dspace-page').should('be.visible'); + + //And wait to list is ready + cy.get('[data-test="objects"]').should('be.visible'); + + //Intercept to await backend response + cy.intercept({ + method: 'GET', + url: '/server/api/discover/search/objects**', + }).as('workflowSearch'); + + //Change view to see workflow tasks + cy.get('ds-search-switch-configuration select option[data-test="workflow"]') + .should('exist') + .invoke('attr', 'value') + .then(value => { + cy.get('ds-search-switch-configuration select').select(value); + }); + + //Await backend search response + cy.wait('@workflowSearch'); + + //Validate URL + cy.url().should('include', 'configuration=workflow'); + + //Wait to render the list and at leat one item + cy.get('[data-test="list-object"]').should('have.length.greaterThan', 0); + cy.get('[data-test="claim-button"]').should('exist'); + + //Check that we have at least one item in worflow search, the item have claim-button and can click in it. + cy.get('[data-test="list-object"]') + .then(($items) => { + const itemWithClaim = [...$items].find(item => + item.querySelector('[data-test="claim-button"]'), + ); + cy.wrap(itemWithClaim).should('exist'); + cy.wrap(itemWithClaim).as('selectedItem'); + cy.wrap(itemWithClaim).within(() => { + cy.get('ds-pool-task-actions').should('exist'); + cy.get('[data-test="claim-button"]').click(); + }); + }); + + //Finally, when you click the ‘Claim’ button, the actions for the selected item change + cy.get('@selectedItem').within(() => { + cy.get('ds-claimed-task-actions').should('exist'); + }); + }); }); diff --git a/cypress/fixtures/example.pdf b/cypress/fixtures/example.pdf new file mode 100644 index 00000000000..c01805e89c1 Binary files /dev/null and b/cypress/fixtures/example.pdf differ diff --git a/docker/README.md b/docker/README.md index 6360124b601..2030a8f657c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,7 +20,8 @@ the Docker compose scripts in this 'docker' folder. ### Dockerfile -This Dockerfile is used to build a *development* DSpace Angular UI image, published as 'dspace/dspace-angular' +This Dockerfile is used to build a *development* mode DSpace Angular UI image, published as 'dspace/dspace-angular'. Because it uses development mode, this image supports "live reloading" of the user interface +when local source code is modified. ``` docker build -t dspace/dspace-angular:dspace-8_x . @@ -35,7 +36,7 @@ docker push dspace/dspace-angular:dspace-8_x ### Dockerfile.dist -The `Dockerfile.dist` is used to generate a *production* build and runtime environment. +The `Dockerfile.dist` is used to build a *production* mode DSpace Angular UI image, published as 'dspace/dspace-angular' with a `*-dist` tag. Because it uses production mode, this image supports Server Side Rendering (SSR). ```bash # build the latest image diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 98f74148610..0b8f68fa955 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -16,7 +16,7 @@ services: dspace-cli: environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz + - LOADASSETS=${LOADASSETS:-https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz} entrypoint: - /bin/bash - '-c' diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml index 31563ccc083..2f2793b8e1d 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -15,9 +15,9 @@ services: dspace-cli: environment: - - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip - - ADMIN_EMAIL=test@test.edu - - AIPDIR=/tmp/aip-dir + - AIPZIP=${AIPZIP:-https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip} + - ADMIN_EMAIL=${ADMIN_EMAIL:-test@test.edu} + - AIPDIR=${AIPDIR:-/tmp/aip-dir} entrypoint: - /bin/bash - '-c' diff --git a/docker/cli.yml b/docker/cli.yml index 7c17b14b1bd..dde3e358fb8 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -16,7 +16,7 @@ networks: # Default to using network named 'dspacenet' from docker-compose-rest.yml. # Its full name will be prepended with the project name (e.g. "-p d8" means it will be named "d8_dspacenet") # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) - default: + dspacenet: name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet external: true services: @@ -28,19 +28,15 @@ services: # See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml # __P__ => "." (e.g. dspace__P__dir => dspace.dir) # __D__ => "-" (e.g. google__D__metadata => google-metadata) - # dspace.dir - dspace__P__dir: /dspace # db.url: Ensure we are using the 'dspacedb' image for our database - db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + db__P__url: ${db__P__url:-jdbc:postgresql://dspacedb:5432/dspace} # solr.server: Ensure we are using the 'dspacesolr' image for Solr - solr__P__server: http://dspacesolr:8983/solr + solr__P__server: ${solr__P__server:-http://dspacesolr:8983/solr} + networks: + - dspacenet volumes: # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore - entrypoint: /dspace/bin/dspace - command: help - tty: true - stdin_open: true volumes: assetstore: diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 464253f07be..d0df252ee1d 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -19,7 +19,7 @@ services: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data # NOTE: currently there is no dspace8 version - - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql + - LOADSQL=${LOADSQL:-https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql} dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### # Ensure that the database is ready BEFORE starting tomcat @@ -35,4 +35,4 @@ services: - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate ignored - java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace + java -jar /dspace/webapps/server-boot.jar diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index d2589bb3f32..4aaf959d856 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -22,17 +22,17 @@ services: # __P__ => "." (e.g. dspace__P__dir => dspace.dir) # __D__ => "-" (e.g. google__D__metadata => google-metadata) # dspace.dir, dspace.server.url and dspace.ui.url - dspace__P__dir: /dspace - dspace__P__server__P__url: http://127.0.0.1:8080/server - dspace__P__ui__P__url: http://127.0.0.1:4000 + dspace__P__dir: ${dspace__P__dir:-/dspace} + dspace__P__server__P__url: ${dspace__P__server__P__url:-http://127.0.0.1:8080/server} + dspace__P__ui__P__url: ${dspace__P__ui__P__url:-http://127.0.0.1:4000} # db.url: Ensure we are using the 'dspacedb' image for our database - db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + db__P__url: ${db__P__url:-jdbc:postgresql://dspacedb:5432/dspace} # solr.server: Ensure we are using the 'dspacesolr' image for Solr - solr__P__server: http://dspacesolr:8983/solr + solr__P__server: ${solr__P__server:-http://dspacesolr:8983/solr} # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. - solr__D__statistics__P__autoCommit: 'false' - LOGGING_CONFIG: /dspace/config/log4j2-container.xml + solr__D__statistics__P__autoCommit: ${solr__D__statistics__P__autoCommit:-false} + LOGGING_CONFIG: ${LOGGING_CONFIG:-/dspace/config/log4j2-container.xml} image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb @@ -41,8 +41,6 @@ services: ports: - published: 8080 target: 8080 - stdin_open: true - tty: true volumes: - assetstore:/dspace/assetstore # Ensure that the database is ready BEFORE starting tomcat @@ -74,8 +72,6 @@ services: ports: - published: 5432 target: 5432 - stdin_open: true - tty: true volumes: # Keep Postgres data directory between reboots - pgdata:/pgdata @@ -88,8 +84,6 @@ services: ports: - published: 8983 target: 8983 - stdin_open: true - tty: true working_dir: /var/solr/data volumes: # Keep Solr data directory between reboots diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 03e5e9da709..e35f76e16ca 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -9,31 +9,32 @@ # Docker Compose for running the DSpace Angular UI dist build # for previewing with the DSpace Demo site backend networks: + # Default to using network named 'dspacenet' from docker-compose.yml. + # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") dspacenet: + name: ${COMPOSE_PROJECT_NAME}_dspacenet + external: true services: dspace-angular: container_name: dspace-angular environment: - DSPACE_UI_SSL: 'false' - DSPACE_UI_HOST: dspace-angular - DSPACE_UI_PORT: '4000' - DSPACE_UI_NAMESPACE: / - # NOTE: When running the UI in production mode (which the -dist image does), - # these DSPACE_REST_* variables MUST point at a public, HTTPS URL. - # This is because Server Side Rendering (SSR) currently requires a public URL, - # see this bug: https://github.com/DSpace/dspace-angular/issues/1485 - DSPACE_REST_SSL: 'true' - DSPACE_REST_HOST: sandbox.dspace.org - DSPACE_REST_PORT: 443 - DSPACE_REST_NAMESPACE: /server + DSPACE_UI_SSL: ${DSPACE_UI_SSL:-false} + DSPACE_UI_HOST: ${DSPACE_UI_HOST:-dspace-angular} + DSPACE_UI_PORT: ${DSPACE_UI_PORT:-4000} + DSPACE_UI_NAMESPACE: ${DSPACE_UI_NAMESPACE:-/} + DSPACE_UI_BASEURL: ${DSPACE_UI_BASEURL:-http://localhost:4000} + DSPACE_REST_SSL: ${DSPACE_REST_SSL:-false} + DSPACE_REST_HOST: ${DSPACE_REST_HOST:-localhost} + DSPACE_REST_PORT: ${DSPACE_REST_PORT:-8080} + DSPACE_REST_NAMESPACE: ${DSPACE_REST_NAMESPACE:-/server} + # Ensure SSR can use the 'dspace' Docker image directly (see docker-compose-rest.yml) + DSPACE_REST_SSRBASEURL: ${DSPACE_REST_SSRBASEURL:-http://dspace:8080/server} image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}-dist" build: context: .. dockerfile: Dockerfile.dist networks: - dspacenet: + - dspacenet ports: - published: 4000 target: 4000 - stdin_open: true - tty: true diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index 37a5d23e77b..f794a026f8b 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -17,6 +17,10 @@ networks: # Define a custom subnet for our DSpace network, so that we can easily trust requests from host to container. # If you customize this value, be sure to customize the 'proxies.trusted.ipranges' env variable below. - subnet: 172.23.0.0/16 + # Explicitly set external=false because this script creates the network. + # NOTE: Because of how compose files are merged, this script should be specified LAST when passed + # to "docker compose" for the network to be created properly. + external: false services: # DSpace (backend) webapp container dspace: @@ -26,20 +30,22 @@ services: # See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml # __P__ => "." (e.g. dspace__P__dir => dspace.dir) # __D__ => "-" (e.g. google__D__metadata => google-metadata) - # dspace.dir, dspace.server.url, dspace.ui.url and dspace.name - dspace__P__dir: /dspace - # Uncomment to set a non-default value for dspace.server.url or dspace.ui.url + # Uncomment to set a non-default value for dspace.dir, dspace.server.url or dspace.ui.url + # dspace__P__dir: /dspace # dspace__P__server__P__url: http://localhost:8080/server # dspace__P__ui__P__url: http://localhost:4000 - dspace__P__name: 'DSpace Started with Docker Compose' + # Set SSR URL to the Docker container name so that UI can contact container directly in Production mode. + # (This is necessary for docker-compose-dist.yml) + dspace__P__server__P__ssr__P__url: ${dspace__P__server__P__ssr__P__url:-http://dspace:8080/server} + dspace__P__name: ${dspace__P__name:-DSpace Started with Docker Compose} # db.url: Ensure we are using the 'dspacedb' image for our database - db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + db__P__url: ${db__P__url:-jdbc:postgresql://dspacedb:5432/dspace} # solr.server: Ensure we are using the 'dspacesolr' image for Solr - solr__P__server: http://dspacesolr:8983/solr + solr__P__server: ${solr__P__server:-http://dspacesolr:8983/solr} # 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' - LOGGING_CONFIG: /dspace/config/log4j2-container.xml + proxies__P__trusted__P__ipranges: ${proxies__P__trusted__P__ipranges:-172.23.0} + LOGGING_CONFIG: ${LOGGING_CONFIG:-/dspace/config/log4j2-container.xml} image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb @@ -48,8 +54,6 @@ services: ports: - published: 8080 target: 8080 - stdin_open: true - tty: true volumes: # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore @@ -63,7 +67,7 @@ services: - | while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate - java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace + java -jar /dspace/webapps/server-boot.jar # DSpace database container dspacedb: container_name: dspacedb @@ -77,8 +81,6 @@ services: ports: - published: 5432 target: 5432 - stdin_open: true - tty: true volumes: # Keep Postgres data directory between reboots - pgdata:/pgdata @@ -91,8 +93,6 @@ services: ports: - published: 8983 target: 8983 - stdin_open: true - tty: true working_dir: /var/solr/data volumes: # Keep Solr data directory between reboots diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8e85520f9fa..993954bfe91 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,29 +10,35 @@ # Requires also running a REST API backend (either locally or remotely), # for example via 'docker-compose-rest.yml' networks: + # Default to using an existing external network named 'dspacenet' (created in docker-compose-rest.yml) + # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") dspacenet: + name: ${COMPOSE_PROJECT_NAME}_dspacenet + external: true services: dspace-angular: container_name: dspace-angular environment: - DSPACE_UI_SSL: 'false' - DSPACE_UI_HOST: dspace-angular - DSPACE_UI_PORT: '4000' - DSPACE_UI_NAMESPACE: / - DSPACE_REST_SSL: 'false' - DSPACE_REST_HOST: localhost - DSPACE_REST_PORT: 8080 - DSPACE_REST_NAMESPACE: /server + DSPACE_UI_SSL: ${DSPACE_UI_SSL:-false} + DSPACE_UI_HOST: ${DSPACE_UI_HOST:-dspace-angular} + DSPACE_UI_PORT: ${DSPACE_UI_PORT:-4000} + DSPACE_UI_NAMESPACE: ${DSPACE_UI_NAMESPACE:-/} + DSPACE_REST_SSL: ${DSPACE_REST_SSL:-false} + DSPACE_REST_HOST: ${DSPACE_REST_HOST:-localhost} + DSPACE_REST_PORT: ${DSPACE_REST_PORT:-8080} + DSPACE_REST_NAMESPACE: ${DSPACE_REST_NAMESPACE:-/server} image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}" build: context: .. dockerfile: Dockerfile networks: - dspacenet: + - dspacenet ports: - published: 4000 target: 4000 - published: 9876 target: 9876 - stdin_open: true - tty: true + volumes: + # Mount the local 'src' directory to the '/app' directory on the container. + # Allows the UI to "watch" this directory for changes and reload/rebuild when changes are detected. + - ../src:/app/src diff --git a/package.json b/package.json index 10600248b76..32983fc67f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "8.3.0", + "version": "8.5.0", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -19,7 +19,7 @@ "build:lint": "rimraf 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json", "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=ChromeHeadlessCI --code-coverage", + "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "test:lint": "yarn build:lint && yarn test:lint:nobuild", "test:lint:nobuild": "jasmine --config=lint/jasmine.json", "lint": "yarn build:lint && yarn lint:nobuild", @@ -54,6 +54,9 @@ "https": false }, "private": true, + "resolutions": { + "webpack": "5.107.2" + }, "dependencies": { "@angular/animations": "^17.3.11", "@angular/cdk": "^17.3.10", @@ -67,7 +70,6 @@ "@angular/platform-server": "^17.3.11", "@angular/router": "^17.3.11", "@angular/ssr": "^17.3.17", - "@babel/runtime": "7.28.4", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^16.0.0", @@ -78,44 +80,44 @@ "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "angulartics2": "^12.2.0", - "axios": "^1.13.2", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", "colors": "^1.4.0", "compression": "^1.8.1", "cookie-parser": "1.4.7", - "core-js": "^3.47.0", + "core-js": "^3.49.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", "ejs": "^3.1.10", - "express": "^4.21.2", + "express": "^4.22.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", - "http-proxy-middleware": "^2.0.9", + "http-proxy-middleware": "^2.0.10", "http-terminator": "^3.2.0", - "isbot": "^5.1.32", + "isbot": "^5.2.2", "js-cookie": "2.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "json5": "^2.2.3", "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.18", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", - "morgan": "^1.10.1", - "ng2-file-upload": "9.0.0", + "morgan": "^1.12.0", + "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", "ngx-pagination": "6.0.3", "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^14.1.0", + "node-html-parser": "^7.0.1", "nouislider": "^15.7.1", "pem": "1.14.8", "reflect-metadata": "^0.2.2", @@ -134,7 +136,6 @@ "@angular-eslint/template-parser": "17.5.3", "@angular/cli": "^17.3.17", "@angular/compiler-cli": "^17.3.11", - "@angular/language-service": "^17.3.11", "@cypress/schematic": "^1.5.0", "@fortawesome/fontawesome-free": "^6.7.2", "@material-ui/core": "^4.12.4", @@ -147,18 +148,18 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.21", + "@types/lodash": "^4.17.25", "@types/node": "^14.14.9", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/rule-tester": "^7.2.0", "@typescript-eslint/utils": "^7.2.0", - "axe-core": "^4.11.0", + "axe-core": "^4.13.0", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", "csstype": "^3.2.3", - "cypress": "^13.17.0", + "cypress": "^14.5.4", "cypress-axe": "^1.7.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", @@ -166,9 +167,9 @@ "eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html", "eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-import-newlines": "^1.3.1", + "eslint-plugin-import-newlines": "^1.4.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.21.0", + "eslint-plugin-jsonc": "^2.21.1", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -183,7 +184,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ng-mocks": "^14.14.0", + "ng-mocks": "^14.17.3", "ngx-mask": "14.2.4", "nodemon": "^2.0.22", "postcss": "^8.5", @@ -192,17 +193,15 @@ "postcss-preset-env": "^7.4.2", "prop-types": "^15.8.1", "react": "^16.14.0", - "react-copy-to-clipboard": "^5.1.0", + "react-copy-to-clipboard": "^5.1.1", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "sass": "~1.94.2", + "sass": "~1.99.0", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", - "terser": "5.29.1", "ts-node": "^8.10.2", "typescript": "~5.4.5", - "webpack": "5.101.0", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.2" + "webpack": "5.107.2", + "webpack-cli": "^5.1.4" } } diff --git a/server.ts b/server.ts index 1005374088d..00bd1a71ef9 100644 --- a/server.ts +++ b/server.ts @@ -25,7 +25,6 @@ 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'; @@ -59,6 +58,7 @@ import { RESPONSE, } from './src/express.tokens'; import { SsrExcludePatterns } from "./src/config/ssr-config.interface"; +import { ServerHashedFileMapping } from './src/modules/dynamic-hash/hashed-file-mapping.server'; /* * Set path for the browser application's dist folder @@ -71,7 +71,11 @@ const indexHtml = join(DIST_FOLDER, 'index.html'); const cookieParser = require('cookie-parser'); -const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); +const configJson = join(DIST_FOLDER, 'assets/config.json'); +const hashedFileMapping = new ServerHashedFileMapping(DIST_FOLDER, 'index.html'); +const appConfig: AppConfig = buildAppConfig(configJson, hashedFileMapping); +appConfig.themes.forEach(themeConfig => hashedFileMapping.addThemeStyle(themeConfig.name, themeConfig.prefetch)); +hashedFileMapping.save(); // cache of SSR pages for known bots, only enabled in production mode let botCache: LRU; @@ -147,7 +151,7 @@ export function app() { server.get('/robots.txt', (req, res) => { res.setHeader('content-type', 'text/plain'); res.render('assets/robots.txt.ejs', { - 'origin': req.protocol + '://' + req.headers.host, + 'origin': environment.ui.baseUrl, }); }); @@ -319,15 +323,14 @@ function clientSideRender(req, res) { // Replace base href dynamically html = html.replace( //, - `` + ``, ); // Replace REST URL with UI URL if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); } - - res.send(html); + res.set('Cache-Control', 'no-cache, no-store').send(html); } @@ -338,7 +341,11 @@ function clientSideRender(req, res) { */ function addCacheControl(req, res, next) { // instruct browser to revalidate - res.header('Cache-Control', environment.cache.control || 'max-age=604800'); + if (environment.cache.noCacheFiles.includes(req.originalUrl)) { + res.header('Cache-Control', 'no-cache, no-store'); + } else { + res.header('Cache-Control', environment.cache.control || 'max-age=604800'); + } next(); } @@ -665,12 +672,14 @@ function isExcludedFromSsr(path: string, excludePathPattern: SsrExcludePatterns[ */ function healthCheck(req, res) { const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`; - axios.get(baseUrl) + fetch(baseUrl) .then((response) => { - res.status(response.status).send(response.data); + return response.json().then((data) => { + res.status(response.status).send(data); + }); }) .catch((error) => { - res.status(error.response.status).send({ + res.status(error?.response?.status || 503).send({ error: error.message, }); }); 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 c5c94073777..13248c8ef6a 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 @@ -316,15 +316,28 @@ describe('EPersonFormComponent', () => { }); }); + describe('with uppercased email specified', () => { + beforeEach(() => { + component.formGroup.controls.firstName.setValue('test'); + component.formGroup.controls.lastName.setValue('test'); + component.formGroup.controls.email.setValue('TEST@test.com'); + fixture.detectChanges(); + }); + + it('passes validation check', () => { + expect(component.formGroup.controls.email.valid).toBeTrue(); + expect(component.formGroup.controls.email.errors).toBeNull(); + }); + }); describe('after inserting email wrong should show pattern validation error', () => { beforeEach(() => { - component.formGroup.controls.email.setValue('test@test'); + component.formGroup.controls.email.setValue('test'); fixture.detectChanges(); }); 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(); + expect(component.formGroup.controls.email.errors.email).toBeTruthy(); }); }); 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 60d221e1160..ea1cd9b67a5 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 @@ -307,12 +307,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy { name: 'email', validators: { required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', + email: null, }, required: true, errorMessages: { emailTaken: 'error.validation.emailTaken', - pattern: 'error.validation.NotValidEmail', + email: 'error.validation.NotValidEmail', }, hint: this.translateService.instant(`${this.messagePrefix}.emailHint`), }); diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 0dd4515a73c..6659d6d548b 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -47,7 +47,7 @@ import { provideSubmissionState } from './submission/provide-submission-state'; import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; export const APP_ROUTES: Route[] = [ - { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent }, + { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent, data: { title: '500.page-internal-server-error' } }, { path: ERROR_PAGE, component: ThemedPageErrorComponent }, { path: '', @@ -246,6 +246,7 @@ export const APP_ROUTES: Route[] = [ { path: FORBIDDEN_PATH, component: ThemedForbiddenComponent, + data: { title: '403.forbidden' }, }, { path: 'statistics', @@ -269,7 +270,7 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, - { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, + { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent, data: { title: '404.page-not-found' } }, ], }, ]; diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 77b29206cb5..5cf201bb761 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -36,6 +36,8 @@ import { } from '../config/app-config.interface'; import { StoreDevModules } from '../config/store/devtools'; import { environment } from '../environments/environment'; +import { HashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping'; +import { BrowserHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.browser'; import { EagerThemesModule } from '../themes/eager-themes.module'; import { appEffects } from './app.effects'; import { @@ -154,6 +156,10 @@ export const commonAppConfig: ApplicationConfig = { useClass: DspaceRestInterceptor, multi: true, }, + { + provide: HashedFileMapping, + useClass: BrowserHashedFileMapping, + }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, provideCore(), diff --git a/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.spec.ts b/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.spec.ts index 1eb0e00b85f..3c325c65361 100644 --- a/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.spec.ts +++ b/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.spec.ts @@ -1,6 +1,7 @@ import { cold } from 'jasmine-marbles'; import { EMPTY } from 'rxjs'; +import { environment } from '../../environments/environment'; import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { RemoteData } from '../core/data/remote-data'; @@ -150,7 +151,7 @@ describe('legacyBitstreamURLRedirectGuard', () => { })); resolver(route, state, bitstreamDataService, hardRedirectService, router).subscribe(() => { expect(bitstreamDataService.findByItemHandle).toHaveBeenCalled(); - expect(hardRedirectService.redirect).toHaveBeenCalledWith(new URL(`/bitstreams/${bitstream.uuid}/download`, window.location.origin).href, 301); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(new URL(`/bitstreams/${bitstream.uuid}/download`, environment.ui.baseUrl).href, 301); }); }); }); diff --git a/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts b/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts index 78403ed7e3f..75d179e4493 100644 --- a/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts +++ b/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts @@ -9,7 +9,10 @@ import { import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; +import { + getBitstreamDownloadRoute, + PAGE_NOT_FOUND_PATH, +} from '../app-routing-paths'; import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { RemoteData } from '../core/data/remote-data'; import { HardRedirectService } from '../core/services/hard-redirect.service'; @@ -47,7 +50,7 @@ export const legacyBitstreamURLRedirectGuard: CanActivateFn = ( getFirstCompletedRemoteData(), map((rd: RemoteData) => { if (rd.hasSucceeded && !rd.hasNoContent) { - serverHardRedirectService.redirect(new URL(`/bitstreams/${rd.payload.uuid}/download`, serverHardRedirectService.getCurrentOrigin()).href, 301); + serverHardRedirectService.redirect(new URL(getBitstreamDownloadRoute(rd.payload), serverHardRedirectService.getBaseUrl()).href, 301); return false; } else { return router.createUrlTree([PAGE_NOT_FOUND_PATH]); diff --git a/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts index fad573705c5..80e826f7fd6 100644 --- a/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts @@ -95,8 +95,8 @@ describe('BrowseByDateComponent', () => { findById: () => createSuccessfulRemoteDataObject$(mockCommunity), }; - const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}), + const activatedRouteStub = Object.assign(new ActivatedRouteStub({ id: 'dateissued' }), { + params: observableOf({ id: 'dateissued' }), queryParams: observableOf({}), data: observableOf({ metadata: 'dateissued', metadataField: 'dc.date.issued' }), }); @@ -151,9 +151,8 @@ describe('BrowseByDateComponent', () => { fixture = TestBed.createComponent(BrowseByDateComponent); const browseService = fixture.debugElement.injector.get(BrowseService); spyOn(browseService, 'getFirstItemFor') - // ok to expect the default browse as first param since we just need the mock items obtained via sort direction. - .withArgs('author', undefined, SortDirection.ASC).and.returnValue(createSuccessfulRemoteDataObject$(firstItem)) - .withArgs('author', undefined, SortDirection.DESC).and.returnValue(createSuccessfulRemoteDataObject$(lastItem)); + .withArgs('dateissued', undefined, SortDirection.ASC).and.returnValue(createSuccessfulRemoteDataObject$(firstItem)) + .withArgs('dateissued', undefined, SortDirection.DESC).and.returnValue(createSuccessfulRemoteDataObject$(lastItem)); comp = fixture.componentInstance; route = (comp as any).route; fixture.detectChanges(); diff --git a/src/app/browse-by/browse-by-date/browse-by-date.component.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.ts index 1ebdcc838ea..c0147132e3f 100644 --- a/src/app/browse-by/browse-by-date/browse-by-date.component.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -118,7 +118,7 @@ export class BrowseByDateComponent extends BrowseByMetadataComponent implements this.currentSort$, ]).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id; this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails); this.updatePageWithItems(searchOptions, this.value, undefined); diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts index 623f1af8aaf..78d45123df5 100644 --- a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts @@ -123,8 +123,8 @@ describe('BrowseByMetadataComponent', () => { findById: () => createSuccessfulRemoteDataObject$(mockCommunity), }; - const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}), + const activatedRouteStub = Object.assign(new ActivatedRouteStub({ id: 'author' }), { + params: observableOf({ id: 'author' }), }); paginationService = new PaginationServiceStub(); diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts index 4cb07abd879..4fc7f3ca3ba 100644 --- a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts @@ -141,15 +141,10 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy { */ subs: Subscription[] = []; - /** - * The default browse id to resort to when none is provided - */ - defaultBrowseId = 'author'; - /** * The current browse id */ - browseId = this.defaultBrowseId; + browseId: string; /** * The type of StartsWith options to render @@ -235,7 +230,7 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy { this.currentPagination$, this.currentSort$, ]).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id; this.authority = params.authority; if (typeof params.value === 'string') { diff --git a/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts index ca6efa16b90..2372c7d0c75 100644 --- a/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -154,7 +154,7 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy { this.facetType = browseDefinition.facetType; this.vocabularyName = browseDefinition.vocabulary; this.vocabularyOptions = { name: this.vocabularyName, closed: true }; - this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.descrption`); + this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.description`); })); this.subs.push(this.scope$.subscribe(() => { this.updateQueryParams(); diff --git a/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts index 1b6d96d3f86..ce5c409db2c 100644 --- a/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts @@ -82,8 +82,8 @@ describe('BrowseByTitleComponent', () => { findById: () => createSuccessfulRemoteDataObject$(mockCommunity), }; - const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({}), + const activatedRouteStub = Object.assign(new ActivatedRouteStub({ id: 'title' }), { + params: observableOf({ id: 'title' }), queryParams: observableOf({}), data: observableOf({ metadata: 'title' }), }); diff --git a/src/app/browse-by/browse-by-title/browse-by-title.component.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.ts index 7d6e3ce716e..f4c30a842b4 100644 --- a/src/app/browse-by/browse-by-title/browse-by-title.component.ts +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.ts @@ -73,7 +73,7 @@ export class BrowseByTitleComponent extends BrowseByMetadataComponent implements this.currentSort$, ]).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { this.startsWith = +params.startsWith || params.startsWith; - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id; this.updatePageWithItems(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); })); this.updateStartsWithTextOptions(); diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index 8b3bb6e46eb..cb1e87cf055 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -9,7 +9,7 @@
diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 03b6bc11910..802d61d169c 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -69,8 +69,16 @@ export class AuthenticatedAction implements Action { public type: string = AuthActionTypes.AUTHENTICATED; payload: AuthTokenInfo; - constructor(token: AuthTokenInfo) { + /** + * Whether we should consider the given authentication info final. + * If the backend restarted we may have a token that hasn't expired yet, but it will be invalid anyway. + * In this case we'll have to check twice. + */ + checkAgain: boolean; + + constructor(token: AuthTokenInfo, checkAgain = false) { this.payload = token; + this.checkAgain = checkAgain; } } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index a423455594a..fe27f4118d0 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -156,7 +156,7 @@ describe('AuthEffects', () => { describe('when token is valid', () => { it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => { - actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATED, payload: token } }); + actions = hot('--a-', { a: new AuthenticatedAction(token) }); const expected = cold('--b-', { b: new AuthenticatedSuccessAction(true, token, EPersonMock._links.self.href) }); @@ -164,17 +164,29 @@ describe('AuthEffects', () => { }); }); - describe('when token is not valid', () => { + describe('when token is expired', () => { it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => { spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test'))); - actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATED, payload: token } }); + actions = hot('--a-', { a: new AuthenticatedAction(token) }); const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) }); expect(authEffects.authenticated$).toBeObservable(expected); }); }); + + describe('when token is not valid but also not expired (~ cookie)', () => { + it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => { + spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { a: new AuthenticatedAction(token, true) }); + + const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() }); + + expect(authEffects.authenticated$).toBeObservable(expected); + }); + }); }); describe('authenticatedSuccess$', () => { @@ -210,7 +222,7 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN } }); - const expected = cold('--b-', { b: new AuthenticatedAction(token) }); + const expected = cold('--b-', { b: new AuthenticatedAction(token, true) }); expect(authEffects.checkToken$).toBeObservable(expected); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 2919a40fa85..148a5d7dfc7 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -121,7 +121,13 @@ export class AuthEffects { switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error: unknown) => errorToAuthAction$(AuthenticatedErrorAction, error)), + catchError((error: unknown) => { + if (action.checkAgain) { + return observableOf(new CheckAuthenticationTokenCookieAction()); + } else { + return errorToAuthAction$(AuthenticatedErrorAction, error); + } + }), ); }), )); @@ -176,7 +182,7 @@ export class AuthEffects { public checkToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { return this.authService.hasValidAuthenticationToken().pipe( - map((token: AuthTokenInfo) => new AuthenticatedAction(token)), + map((token: AuthTokenInfo) => new AuthenticatedAction(token, true)), catchError((error: unknown) => observableOf(new CheckAuthenticationTokenCookieAction())), ); }), diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index ee6a5677da0..24c3aac8bbf 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -66,7 +66,47 @@ export class CollectionDataService extends ComColDataService { } /** - * Get all collections the user is authorized to submit to + * Get all collections the user is admin of + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * collection list + */ + getAdminAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdminAuthorized'; + return this.getAuthorizedCollection(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all collections the user is authorized to edit + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * collection list + */ + getEditAuthorizedCollection(query: string,options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findEditAuthorized'; + return this.getAuthorizedCollection(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all collections the user is authorized to submit * * @param query limit the returned collection to those with metadata values * matching the query terms. @@ -77,7 +117,27 @@ export class CollectionDataService extends ComColDataService { * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved + * @return Observable>> + * collection list + */ + getSubmitAuthorizedCollection(query: string,options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findSubmitAuthorized'; + return this.getAuthorizedCollection(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all collections the user is authorized to perform a specific action on + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale * @param searchHref The backend search endpoint to use (default to submit) + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved * @return Observable>> * collection list */ diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index ba4feedbb55..7a216afd5e6 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -40,6 +40,66 @@ export class CommunityDataService extends ComColDataService { super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + /** + * Get all communities the user is admin of + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getAdminAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdminAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all communities the user is authorized to add a new subcommunity or collection to + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getAddAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAddAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all communities the user is authorized to edit + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getEditAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findEditAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + /** * Get all communities the user is authorized to submit to * @@ -50,13 +110,13 @@ export class CommunityDataService extends ComColDataService { * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale + * @param searchHref The search endpoint to use, defaults to 'findAdminAuthorized' * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved * @return Observable>> * community list */ - getAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const searchHref = 'findAdminAuthorized'; + getAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, searchHref: string = 'findAdminAuthorized', ...linksToFollow: FollowLinkConfig[]): Observable>> { options = Object.assign({}, options, { searchParams: [new RequestParam('query', query)], }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 2fdad85b482..5b5923080c4 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -18,6 +18,7 @@ import { switchMap, take, } from 'rxjs/operators'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; import { hasValue, @@ -57,6 +58,7 @@ import { PatchData, PatchDataImpl, } from './base/patch-data'; +import { SearchDataImpl } from './base/search-data'; import { BundleDataService } from './bundle-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindListOptions } from './find-list-options.model'; @@ -83,6 +85,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; + private searchData: SearchDataImpl; protected constructor( protected linkPath, @@ -101,6 +104,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -361,6 +365,26 @@ export abstract class BaseItemDataService extends IdentifiableDataService ); } + /** + * Find the list of items for which the current user has editing rights. + * + * @param query limit the returned collection to those with metadata values + * matching the query terms + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * item list + */ + public findEditAuthorized(query: string, options: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + options = { ...options, searchParams: [new RequestParam('query', query)] }; + return this.searchBy('findEditAuthorized', options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Invalidate the cache of the item * @param itemUUID @@ -377,6 +401,24 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.patchData.commitUpdates(method); } + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Send a patch request for a specified object * @param {T} object The object to send a patch request for diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts index dcc4b7e257c..8806f937c01 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -18,11 +18,12 @@ describe(`LocaleInterceptor`, () => { let localeService: any; const languageList = ['en;q=1', 'it;q=0.9', 'de;q=0.8', 'fr;q=0.7']; + const rootHref = 'https://sandbox.dspace.org/server/api'; - const mockLocaleService = jasmine.createSpyObj('LocaleService', { - getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), - getLanguageCodeList: of(languageList), - }); + const mockLocaleService = jasmine.createSpyObj('LocaleService', [ + 'getCurrentLanguageCode', + 'getLanguageCodeList', + ]); const mockHalEndpointService = { getRootHref: jasmine.createSpy('getRootHref'), @@ -48,6 +49,8 @@ describe(`LocaleInterceptor`, () => { localeService = TestBed.inject(LocaleService); localeService.getCurrentLanguageCode.and.returnValue(of('en')); + localeService.getLanguageCodeList.and.returnValue(of(languageList)); + mockHalEndpointService.getRootHref.and.returnValue(rootHref); }); describe('', () => { @@ -76,6 +79,29 @@ describe(`LocaleInterceptor`, () => { const lang = httpRequest.request.headers.get('Accept-Language'); expect(lang).toBeDefined(); expect(lang).toBe(languageList.toString()); + expect(localeService.getLanguageCodeList).toHaveBeenCalledWith(false); + }); + + it('should ignore EPerson settings for root endpoint requests', () => { + service.request(RestRequestMethod.GET, rootHref).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + httpMock.expectOne(rootHref); + + expect(localeService.getLanguageCodeList).toHaveBeenCalledWith(true); + }); + + it('should ignore EPerson settings for eperson endpoint requests', () => { + const epersonHref = `${rootHref}/eperson/epersons/1234`; + + service.request(RestRequestMethod.GET, epersonHref).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + httpMock.expectOne(epersonHref); + + expect(localeService.getLanguageCodeList).toHaveBeenCalledWith(true); }); }); diff --git a/src/app/core/locale/locale.interceptor.ts b/src/app/core/locale/locale.interceptor.ts index a415ab8c514..f005b333b12 100644 --- a/src/app/core/locale/locale.interceptor.ts +++ b/src/app/core/locale/locale.interceptor.ts @@ -31,7 +31,9 @@ export class LocaleInterceptor implements HttpInterceptor { */ intercept(req: HttpRequest, next: HttpHandler): Observable> { let newReq: HttpRequest; - return this.localeService.getLanguageCodeList(req.url === this.halEndpointService.getRootHref()) + const ignoreEPersonSettings: boolean = this.shouldIgnoreEPersonSettings(req.url); + + return this.localeService.getLanguageCodeList(ignoreEPersonSettings) .pipe( take(1), scan((acc: any, value: any) => [...acc, value], []), @@ -45,4 +47,12 @@ export class LocaleInterceptor implements HttpInterceptor { return next.handle(newReq); })); } + + /** + * Avoid recursive EPerson language lookup for requests that are needed to resolve EPerson itself. + */ + private shouldIgnoreEPersonSettings(url: string): boolean { + const rootHref = this.halEndpointService.getRootHref(); + return url === rootHref || url.startsWith(`${rootHref}/eperson/epersons`); + } } diff --git a/src/app/core/metadata/head-tag.service.spec.ts b/src/app/core/metadata/head-tag.service.spec.ts index da4fa97ee98..96723fe6a96 100644 --- a/src/app/core/metadata/head-tag.service.spec.ts +++ b/src/app/core/metadata/head-tag.service.spec.ts @@ -97,7 +97,7 @@ describe('HeadTagService', () => { }, } as any as Router; hardRedirectService = jasmine.createSpyObj( { - getCurrentOrigin: 'https://request.org', + getBaseUrl: 'https://request.org', }); authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: observableOf(true), diff --git a/src/app/core/metadata/head-tag.service.ts b/src/app/core/metadata/head-tag.service.ts index efe585733b0..801a95c0fe5 100644 --- a/src/app/core/metadata/head-tag.service.ts +++ b/src/app/core/metadata/head-tag.service.ts @@ -324,7 +324,7 @@ export class HeadTagService { if (this.currentObject.value instanceof Item) { let url = this.getMetaTagValue('dc.identifier.uri'); if (hasNoValue(url)) { - url = new URLCombiner(this.hardRedirectService.getCurrentOrigin(), this.router.url).toString(); + url = new URLCombiner(this.hardRedirectService.getBaseUrl(), this.router.url).toString(); } this.addMetaTag('citation_abstract_html_url', url); } @@ -407,7 +407,7 @@ export class HeadTagService { // Use the found link to set the tag this.addMetaTag( 'citation_pdf_url', - new URLCombiner(this.hardRedirectService.getCurrentOrigin(), link).toString(), + new URLCombiner(this.hardRedirectService.getBaseUrl(), link).toString(), ); }); } diff --git a/src/app/core/resource-policy/models/action-type.model.ts b/src/app/core/resource-policy/models/action-type.model.ts index 93c69c37052..69da5b37607 100644 --- a/src/app/core/resource-policy/models/action-type.model.ts +++ b/src/app/core/resource-policy/models/action-type.model.ts @@ -15,7 +15,7 @@ export enum ActionType { /** * Action of deleting something */ - DELETE = 'DELETE', + DELETE = 'OBSOLETE (DELETE)', /** * Action of adding something to a container diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts index 859bfdc8aa8..4d4e98b71e1 100644 --- a/src/app/core/services/browser-hard-redirect.service.spec.ts +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -1,11 +1,13 @@ import { TestBed } from '@angular/core/testing'; +import { environment } from '../../../environments/environment'; import { BrowserHardRedirectService } from './browser-hard-redirect.service'; describe('BrowserHardRedirectService', () => { let origin: string; let mockLocation: Location; let service: BrowserHardRedirectService; + let originalBaseUrl; beforeEach(() => { origin = 'https://test-host.com:4000'; @@ -20,11 +22,22 @@ describe('BrowserHardRedirectService', () => { } as Location; spyOn(mockLocation, 'replace'); + // Store original environment variable to restore after tests + originalBaseUrl = environment.ui.baseUrl; + + // Set environment variable to match our mock location origin for testing + environment.ui.baseUrl = origin; + service = new BrowserHardRedirectService(mockLocation); TestBed.configureTestingModule({}); }); + afterEach(() => { + // Restore original environment variable after tests + environment.ui.baseUrl = originalBaseUrl; + }); + it('should be created', () => { expect(service).toBeTruthy(); }); @@ -52,7 +65,7 @@ describe('BrowserHardRedirectService', () => { describe('when requesting the origin', () => { it('should return the location origin', () => { - expect(service.getCurrentOrigin()).toEqual(origin); + expect(service.getBaseUrl()).toEqual(origin); }); }); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index c386e82f2e9..a85b0cf1370 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -4,6 +4,7 @@ import { InjectionToken, } from '@angular/core'; +import { environment } from '../../../environments/environment'; import { HardRedirectService } from './hard-redirect.service'; export const LocationToken = new InjectionToken('Location'); @@ -41,12 +42,11 @@ export class BrowserHardRedirectService extends HardRedirectService { } /** - * Get the origin of the current URL + * Get the base public URL of our application. + * This is used as the base URL for redirects, and should be in the format of * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo.dspace.org/search?query=test, - * the origin would be https://demo.dspace.org */ - getCurrentOrigin(): string { - return this.location.origin; + getBaseUrl(): string { + return environment.ui.baseUrl; } } diff --git a/src/app/core/services/browser.referrer.service.spec.ts b/src/app/core/services/browser.referrer.service.spec.ts index 90ce43c995b..f4aaca93e2c 100644 --- a/src/app/core/services/browser.referrer.service.spec.ts +++ b/src/app/core/services/browser.referrer.service.spec.ts @@ -16,7 +16,7 @@ describe(`BrowserReferrerService`, () => { service = new BrowserReferrerService( { referrer: documentReferrer }, routeService, - { getCurrentOrigin: () => origin } as any, + { getBaseUrl: () => origin } as any, ); }); diff --git a/src/app/core/services/browser.referrer.service.ts b/src/app/core/services/browser.referrer.service.ts index fd2d3d5efd3..c0d980388c5 100644 --- a/src/app/core/services/browser.referrer.service.ts +++ b/src/app/core/services/browser.referrer.service.ts @@ -50,7 +50,7 @@ export class BrowserReferrerService extends ReferrerService { const reversedHistory = [...history].reverse(); // and find the first URL that differs from the current one const prevUrl = reversedHistory.find((url: string) => url !== currentURL); - return new URLCombiner(this.hardRedirectService.getCurrentOrigin(), prevUrl).toString(); + return new URLCombiner(this.hardRedirectService.getBaseUrl(), prevUrl).toString(); } }), ); diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts index e6104cefb9c..023b84a8af3 100644 --- a/src/app/core/services/hard-redirect.service.ts +++ b/src/app/core/services/hard-redirect.service.ts @@ -23,10 +23,9 @@ export abstract class HardRedirectService { abstract getCurrentRoute(): string; /** - * Get the origin of the current URL + * Get the base public URL of our application. + * This is used as the base URL for redirects, and should be in the format of * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo.dspace.org/search?query=test, - * the origin would be https://demo.dspace.org */ - abstract getCurrentOrigin(): string; + abstract getBaseUrl(): string; } diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts index a904a8e66cf..df99c37e59c 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -1,6 +1,7 @@ import { TestBed } from '@angular/core/testing'; -import { environment } from '../../../environments/environment.test'; +import { AppConfig } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; import { ServerHardRedirectService } from './server-hard-redirect.service'; describe('ServerHardRedirectService', () => { @@ -8,8 +9,20 @@ describe('ServerHardRedirectService', () => { const mockRequest = jasmine.createSpyObj(['get']); const mockResponse = jasmine.createSpyObj(['redirect', 'end']); - let service: ServerHardRedirectService = new ServerHardRedirectService(environment, mockRequest, mockResponse); + const envConfig = { + rest: { + ssl: true, + host: 'rest.com', + port: 443, + // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: '/api', + baseUrl: 'https://rest.com/server', + }, + } as AppConfig; + + let service: ServerHardRedirectService = new ServerHardRedirectService(envConfig, mockRequest, mockResponse); const origin = 'https://test-host.com:4000'; + let originalBaseUrl; beforeEach(() => { mockRequest.protocol = 'https'; @@ -17,9 +30,20 @@ describe('ServerHardRedirectService', () => { host: 'test-host.com:4000', }; + // Store original environment variable to restore after tests + originalBaseUrl = environment.ui.baseUrl; + + // Set environment variable to match our mock location origin for testing + environment.ui.baseUrl = origin; + TestBed.configureTestingModule({}); }); + afterEach(() => { + // Restore original environment variable after tests + environment.ui.baseUrl = originalBaseUrl; + }); + it('should be created', () => { expect(service).toBeTruthy(); }); @@ -65,14 +89,14 @@ describe('ServerHardRedirectService', () => { describe('when requesting the origin', () => { it('should return the location origin', () => { - expect(service.getCurrentOrigin()).toEqual(origin); + expect(service.getBaseUrl()).toEqual(origin); }); }); describe('when SSR base url is set', () => { const redirect = 'https://private-url:4000/server/api/bitstreams/uuid'; const replacedUrl = 'https://public-url/server/api/bitstreams/uuid'; - const environmentWithSSRUrl: any = { ...environment, ...{ ...environment.rest, rest: { + const environmentWithSSRUrl: any = { ...envConfig, ...{ ...envConfig.rest, rest: { ssrBaseUrl: 'https://private-url:4000/server', baseUrl: 'https://public-url/server', } } }; diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 1592d9bf1cf..b061c53423e 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -11,6 +11,7 @@ import { APP_CONFIG, AppConfig, } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; import { REQUEST, RESPONSE, @@ -18,6 +19,7 @@ import { import { isNotEmpty } from '../../shared/empty.util'; import { HardRedirectService } from './hard-redirect.service'; + /** * Service for performing hard redirects within the server app module */ @@ -88,12 +90,11 @@ export class ServerHardRedirectService extends HardRedirectService { } /** - * Get the origin of the current URL + * Get the base public URL of our application. + * This is used as the base URL for redirects, and should be in the format of * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo.dspace.org/search?query=test, - * the origin would be https://demo.dspace.org */ - getCurrentOrigin(): string { - return this.req.protocol + '://' + this.req.headers.host; + getBaseUrl(): string { + return environment.ui.baseUrl; } } diff --git a/src/app/core/submission/models/sherpa-policies-details.model.ts b/src/app/core/submission/models/opf-policies-details.model.ts similarity index 84% rename from src/app/core/submission/models/sherpa-policies-details.model.ts rename to src/app/core/submission/models/opf-policies-details.model.ts index af4d4a58907..19450477278 100644 --- a/src/app/core/submission/models/sherpa-policies-details.model.ts +++ b/src/app/core/submission/models/opf-policies-details.model.ts @@ -1,25 +1,25 @@ /** * An interface to represent an access condition. */ -export class SherpaPoliciesDetailsObject { +export class JiscOpfPoliciesDetailsObject { /** - * The sherpa policies error + * The Jisc Open Policy Finder policies error */ error: boolean; /** - * The sherpa policies journal details + * The Jisc Open Policy Finder policies journal details */ journals: Journal[]; /** - * The sherpa policies message + * The Jisc Open Policy Finder policies message */ message: string; /** - * The sherpa policies metadata + * The Jisc Open Policy Finder policies metadata */ metadata: Metadata; diff --git a/src/app/core/submission/models/workspaceitem-section-opf-policies.model.ts b/src/app/core/submission/models/workspaceitem-section-opf-policies.model.ts new file mode 100644 index 00000000000..c435d927ef2 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-opf-policies.model.ts @@ -0,0 +1,22 @@ +import { JiscOpfPoliciesDetailsObject } from './opf-policies-details.model'; + +/** + * An interface to represent the submission's item accesses condition. + */ +export interface WorkspaceitemSectionJiscOpfPoliciesObject { + + /** + * The access condition id + */ + id: string; + + /** + * The Jisc Open Policy Finder policies retrievalTime + */ + retrievalTime: string; + + /** + * The Jisc Open Policy Finder policies details + */ + opfResponse: JiscOpfPoliciesDetailsObject; +} diff --git a/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts b/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts deleted file mode 100644 index c57beadbb9b..00000000000 --- a/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SherpaPoliciesDetailsObject } from './sherpa-policies-details.model'; - -/** - * An interface to represent the submission's item accesses condition. - */ -export interface WorkspaceitemSectionSherpaPoliciesObject { - - /** - * The access condition id - */ - id: string; - - /** - * The sherpa policies retrievalTime - */ - retrievalTime: string; - - /** - * The sherpa policies details - */ - sherpaResponse: SherpaPoliciesDetailsObject; -} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index 46a0590977f..4ed0f0a6d54 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -4,7 +4,7 @@ import { WorkspaceitemSectionDuplicatesObject } from './workspaceitem-section-du import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; import { WorkspaceitemSectionIdentifiersObject } from './workspaceitem-section-identifiers.model'; import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; -import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; +import { WorkspaceitemSectionJiscOpfPoliciesObject } from './workspaceitem-section-opf-policies.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; /** @@ -24,7 +24,7 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionLicenseObject | WorkspaceitemSectionCcLicenseObject | WorkspaceitemSectionAccessesObject - | WorkspaceitemSectionSherpaPoliciesObject + | WorkspaceitemSectionJiscOpfPoliciesObject | WorkspaceitemSectionIdentifiersObject | WorkspaceitemSectionDuplicatesObject | string; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index 22627826874..6e540d51a4d 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -102,7 +102,7 @@ cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [dsBtnDisabled]="disabled" [title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate" ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}"> - +
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 82d42e6857c..20b591a35ef 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -49,6 +49,9 @@ [fields]="['dc.title']" [label]="'person.page.name'"> + +
{{"item.page.link.full" | translate}} diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 56cb2b3fa9b..8bfa04e0bef 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -8,6 +8,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { GenericItemPageFieldComponent } from '../../../../item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { ItemPageOrcidFieldComponent } from '../../../../item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../../../item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; @@ -24,7 +25,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail styleUrls: ['./person.component.scss'], templateUrl: './person.component.html', standalone: true, - imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, RelatedItemsComponent, RouterLink, TabbedRelatedEntitiesSearchComponent, AsyncPipe, TranslateModule], + imports: [NgIf, ThemedResultsBackButtonComponent, ThemedItemPageTitleFieldComponent, DsoEditMenuComponent, MetadataFieldWrapperComponent, ThemedThumbnailComponent, GenericItemPageFieldComponent, ItemPageOrcidFieldComponent, RelatedItemsComponent, RouterLink, TabbedRelatedEntitiesSearchComponent, AsyncPipe, TranslateModule], }) /** * The component for displaying metadata and relations of an item of the type Person diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 348118a05c6..e73fefd6bb8 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -84,8 +84,7 @@ scope="row" id="{{ entry.nameStripped }}" headers="{{ bundleName }} name">
- +
{{ entry.name }} diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index bde291e4127..7b993bd11c6 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -15,7 +15,7 @@

{{"item.page.filesection.original.bund

-
+
{{"item.page.filesection.name" | translate}}
{{ dsoNameService.getName(file) }}
diff --git a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.html b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.html new file mode 100644 index 00000000000..8f2aed7f382 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.html @@ -0,0 +1,13 @@ +@if (hasOrcidMetadata) { +
+} diff --git a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.scss b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.scss new file mode 100644 index 00000000000..20d4762d5bc --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.scss @@ -0,0 +1,12 @@ +:host { + .simple-view-element { + margin-bottom: 15px; + } + .simple-view-element-header { + font-size: 1.25rem; + } + .orcid-icon { + height: var(--ds-orcid-icon-height, 16px); + margin-right: 8px; + } +} diff --git a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.spec.ts new file mode 100644 index 00000000000..f02f5b5df38 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.spec.ts @@ -0,0 +1,168 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { APP_CONFIG } from 'src/config/app-config.interface'; + +import { createSuccessfulRemoteDataObject$ } from '../../../../../../app/shared/remote-data.utils'; +import { BrowseService } from '../../../../../core/browse/browse.service'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { ItemPageOrcidFieldComponent } from './item-page-orcid-field.component'; + +describe('ItemPageOrcidFieldComponent', () => { + let component: ItemPageOrcidFieldComponent; + let fixture: ComponentFixture; + let configurationService: jasmine.SpyObj; + + const mockItem = Object.assign(new Item(), { + metadata: { + 'person.identifier.orcid': [ + { + value: '0000-0002-1825-0097', + language: null, + authority: null, + confidence: -1, + place: 0, + }, + ], + }, + }); + + const mockConfigProperty = Object.assign(new ConfigurationProperty(), { + name: 'orcid.domain-url', + values: ['https://sandbox.orcid.org'], + }); + + const mockAppConfig = { + ui: { + ssl: false, + host: 'localhost', + port: 4000, + nameSpace: '/', + }, + markdown: { + enabled: false, + mathjax: false, + }, + }; + + beforeEach(async () => { + configurationService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']); + configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(mockConfigProperty), + ); + + const browseDefinitionDataServiceStub = { + findAll: jasmine.createSpy('findAll').and.returnValue(of({})), + getBrowseDefinitions: jasmine.createSpy('getBrowseDefinitions').and.returnValue(of([])), + }; + + const browseServiceStub = { + getBrowseEntriesFor: jasmine.createSpy('getBrowseEntriesFor').and.returnValue(of({})), + getBrowseDefinitions: jasmine.createSpy('getBrowseDefinitions').and.returnValue(of([])), + }; + + await TestBed.configureTestingModule({ + imports: [ + ItemPageOrcidFieldComponent, + TranslateModule.forRoot(), + ], + providers: [ + { provide: ConfigurationDataService, useValue: configurationService }, + { provide: BrowseDefinitionDataService, useValue: browseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: browseServiceStub }, + { provide: APP_CONFIG, useValue: mockAppConfig }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .compileComponents(); + + fixture = TestBed.createComponent(ItemPageOrcidFieldComponent); + component = fixture.componentInstance; + component.item = mockItem; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should check if item has ORCID', () => { + expect(component.hasOrcid()).toBe(true); + }); + + it('should return false when item has no ORCID', () => { + component.item = Object.assign(new Item(), { metadata: {} }); + expect(component.hasOrcid()).toBe(false); + }); + + it('should set hasOrcidMetadata property on init', () => { + fixture.detectChanges(); + expect(component.hasOrcidMetadata).toBe(true); + }); + + it('should set hasOrcidMetadata to false when item has no ORCID', () => { + component.item = Object.assign(new Item(), { metadata: {} }); + component.ngOnInit(); + expect(component.hasOrcidMetadata).toBe(false); + }); + + it('should construct ORCID URL on init', (done) => { + fixture.detectChanges(); + + component.orcidUrl$.subscribe(url => { + expect(url).toBe('https://sandbox.orcid.org/0000-0002-1825-0097'); + done(); + }); + }); + + it('should extract ORCID ID on init', () => { + fixture.detectChanges(); + expect(component.orcidId).toBe('0000-0002-1825-0097'); + }); + + it('should handle ORCID with leading slash', (done) => { + component.item = Object.assign(new Item(), { + metadata: { + 'person.identifier.orcid': [ + { + value: '/0000-0002-1825-0097', + language: null, + authority: null, + confidence: -1, + place: 0, + }, + ], + }, + }); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.orcidId).toBe('0000-0002-1825-0097'); + + component.orcidUrl$.subscribe(url => { + expect(url).toBe('https://sandbox.orcid.org/0000-0002-1825-0097'); + done(); + }); + }); + + it('should return null when item has no ORCID metadata', (done) => { + component.item = Object.assign(new Item(), { metadata: {} }); + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.orcidId).toBeNull(); + + component.orcidUrl$.subscribe(url => { + expect(url).toBeNull(); + done(); + }); + }); +}); diff --git a/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts new file mode 100644 index 00000000000..ee8cac6c058 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component.ts @@ -0,0 +1,170 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest, + map, + Observable, +} from 'rxjs'; + +import { BrowseService } from '../../../../../core/browse/browse.service'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; +import { ImageField } from '../image-field'; +import { ItemPageFieldComponent } from '../item-page-field.component'; + +@Component({ + selector: 'ds-item-page-orcid-field', + templateUrl: './item-page-orcid-field.component.html', + styleUrls: ['./item-page-orcid-field.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + TranslateModule, + ], +}) +/** + * This component is used for displaying ORCID identifier as a clickable link + */ +export class ItemPageOrcidFieldComponent extends ItemPageFieldComponent implements OnInit { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ + separator: string; + + /** + * Fields (schema.element.qualifier) used to render their values. + * In this component, we want to display values for metadata 'person.identifier.orcid' + */ + fields: string[] = [ + 'person.identifier.orcid', + ]; + + /** + * Label i18n key for the rendered metadata + */ + label = 'item.page.orcid-profile'; + + /** + * Observable for the ORCID URL from configuration + */ + baseUrl$: Observable; + + /** + * ORCID ID (without full URL) + */ + orcidId: string | null; + + /** + * Observable for the full ORCID URL + */ + orcidUrl$: Observable; + + /** + * Whether the item has ORCID metadata + */ + hasOrcidMetadata: boolean; + + /** + * ORCID icon configuration + */ + img: ImageField = { + URI: 'assets/images/orcid.logo.icon.svg', + alt: 'item.page.orcid-icon', + heightVar: '--ds-orcid-icon-height', + }; + + /** + * Creates an instance of ItemPageOrcidFieldComponent. + * + * @param {BrowseDefinitionDataService} browseDefinitionDataService - Service for managing browse definitions + * @param {BrowseService} browseService - Service for browse functionality + * @param {ConfigurationDataService} configurationService - Service for accessing configuration properties + */ + constructor( + protected browseDefinitionDataService: BrowseDefinitionDataService, + protected browseService: BrowseService, + protected configurationService: ConfigurationDataService, + ) { + super(browseDefinitionDataService, browseService); + } + + /** + * Initializes the component and sets up observables for ORCID URL. + * Separates the display value (ORCID ID) from the link URL. + * + * @returns {void} + */ + ngOnInit(): void { + + this.hasOrcidMetadata = this.hasOrcid(); + + this.baseUrl$ = this.configurationService + .findByPropertyName('orcid.domain-url') + .pipe( + getFirstSucceededRemoteDataPayload(), + map((property: ConfigurationProperty) => + property?.values?.length > 0 ? property.values[0] : null, + ), + ); + + const metadata = this.getOrcidMetadata(); + + this.orcidId = metadata?.value.replace(/^\//, '') || null; + + this.orcidUrl$ = combineLatest([ + this.baseUrl$, + ]).pipe( + map(([baseUrl]) => { + if (!baseUrl || !this.orcidId) { + return null; + } + + const cleanBaseUrl = baseUrl.replace(/\/$/, ''); + return `${cleanBaseUrl}/${this.orcidId}`; + }), + ); + } + + /** + * Retrieves the ORCID metadata value from the item. + * Extracts the first ORCID identifier from the item's metadata fields, + * ensuring the value is not empty or whitespace only. + * + * @private + * @returns {MetadataValue | null} The ORCID metadata value if found and valid, null otherwise + */ + private getOrcidMetadata(): MetadataValue | null { + if (!this.item || !this.hasOrcid()) { + return null; + } + + const metadata = this.item.findMetadataSortedByPlace('person.identifier.orcid'); + return metadata.length > 0 && metadata[0].value?.trim() ? metadata[0] : null; + } + + /** + * Checks whether the item has ORCID metadata associated with it. + * + * @public + * @returns {boolean} True if the item has 'person.identifier.orcid' metadata, false otherwise + */ + public hasOrcid(): boolean { + return this.item?.hasMetadata('person.identifier.orcid'); + } +} diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index e1ae24ff4d2..aa865cd6598 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -68,6 +68,7 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; +import { ItemPageOrcidFieldComponent } from '../../field-components/specific-field/orcid/item-page-orcid-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../field-components/specific-field/title/themed-item-page-field.component'; import { ThemedMetadataRepresentationListComponent } from '../../metadata-representation-list/themed-metadata-representation-list.component'; import { TabbedRelatedEntitiesSearchComponent } from '../../related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; @@ -200,6 +201,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) { RelatedItemsComponent, TabbedRelatedEntitiesSearchComponent, ThemedMetadataRepresentationListComponent, + ItemPageOrcidFieldComponent, ], }, add: { changeDetection: ChangeDetectionStrategy.Default }, diff --git a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html index 77370f462d8..c7345f36f9c 100644 --- a/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html +++ b/src/app/item-page/simple/qa-event-notification/qa-event-notification.component.html @@ -9,7 +9,11 @@
- {{'item.qa-event-notification.check.notification-info' | translate : {num: source.totalEvents } }} + @if (source.totalEvents === 1) { + {{'item.qa-event-notification.check.notification-info.singular' | translate : {num: source.totalEvents } }} + } @else { + {{'item.qa-event-notification.check.notification-info.plural' | translate : {num: source.totalEvents } }} + }
- {{ "mydspace.qa-event-notification.check.notification-info" | translate : { num: source.totalEvents } }} + @if (source.totalEvents === 1) { + {{ "mydspace.qa-event-notification.check.notification-info.singular" | translate : { num: source.totalEvents } }} + } @else { + {{ "mydspace.qa-event-notification.check.notification-info.plural" | translate : { num: source.totalEvents } }} + }
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 5159a408bdd..5b6f9f32f60 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -10,6 +10,7 @@ import { } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { environment } from '../../../../../environments/environment'; import { @@ -42,6 +43,7 @@ export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWra objectType = DSpaceObjectType.COLLECTION; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.CREATE; + rpActionType = ActionType.ADD; header = 'dso-selector.create.collection.sub-level'; defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html index 1c195e257cd..6548ff671d8 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -17,6 +17,7 @@ {{'dso-selector.create.community.sub-level' | translate}} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts index 0e950dc562c..5ec260f729b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -14,6 +14,7 @@ import { import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { environment } from '../../../../../environments/environment'; import { @@ -58,6 +59,7 @@ export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrap objectType = DSpaceObjectType.COMMUNITY; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.CREATE; + rpActionType = ActionType.ADD; defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); isAdmin$: Observable; @@ -66,6 +68,7 @@ export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrap } ngOnInit() { + super.ngOnInit(); this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); } diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html index 5288f08e02b..fe7930b1ac0 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -9,6 +9,7 @@ diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index ede3cc36095..289b34a9fd0 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { environment } from '../../../../../environments/environment'; import { @@ -42,6 +43,7 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo objectType = DSpaceObjectType.ITEM; selectorTypes = [DSpaceObjectType.COLLECTION]; action = SelectorActionType.CREATE; + rpActionType = ActionType.ADD; header = 'dso-selector.create.item.sub-level'; defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html index 92041b39705..9a17b1aa9b2 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -7,7 +7,7 @@ {{header | translate}} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts index 3972a8053b5..74ad372cb61 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { environment } from '../../../../../environments/environment'; import { getCollectionEditRoute } from '../../../../collection-page/collection-page-routing-paths'; @@ -39,6 +40,8 @@ export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComp objectType = DSpaceObjectType.COLLECTION; selectorTypes = [DSpaceObjectType.COLLECTION]; action = SelectorActionType.EDIT; + // for editing collections, admin permissions are required + rpActionType = ActionType.ADMIN; defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html index 1f8e6ec79f6..1ef1806d62b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -7,6 +7,7 @@ {{header | translate}} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts index dda75a7d936..1eb4c95ff29 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { environment } from '../../../../../environments/environment'; import { getCommunityEditRoute } from '../../../../community-page/community-page-routing-paths'; @@ -40,6 +41,8 @@ export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperCompo objectType = DSpaceObjectType.COMMUNITY; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.EDIT; + // for editing communities, admin permissions are required + rpActionType = ActionType.ADMIN; defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html index 999a96e7301..e79dbaeb671 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -6,6 +6,9 @@ diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts index 36f38a48d8b..af17dcaabfa 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Item } from '../../../../core/shared/item.model'; import { MetadataValue } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { RouterStub } from '../../../testing/router.stub'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedItemSelectorComponent } from '../../dso-selector/authorized-item-selector/authorized-item-selector.component'; import { EditItemSelectorComponent } from './edit-item-selector.component'; describe('EditItemSelectorComponent', () => { @@ -67,7 +67,7 @@ describe('EditItemSelectorComponent', () => { }) .overrideComponent(EditItemSelectorComponent, { remove: { - imports: [DSOSelectorComponent], + imports: [AuthorizedItemSelectorComponent], }, }) .compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 903f4327344..f1099f6c8b6 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -9,12 +9,13 @@ import { } from '@angular/router'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { Item } from '../../../../core/shared/item.model'; import { getItemEditRoute } from '../../../../item-page/item-page-routing-paths'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedItemSelectorComponent } from '../../dso-selector/authorized-item-selector/authorized-item-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -29,12 +30,13 @@ import { selector: 'ds-base-edit-item-selector', templateUrl: 'edit-item-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedItemSelectorComponent, TranslateModule], }) export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.ITEM; selectorTypes = [DSpaceObjectType.ITEM]; action = SelectorActionType.EDIT; + rpActionType = ActionType.WRITE; constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index a4c406bf72c..3d5d2a56965 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,9 +1,8 @@
- -
+
+ [attr.aria-label]="model.label">
{ hierarchical: false, }), Object.assign(new ExternalSource(), { - id: 'sherpaPublisher', - name: 'sherpaPublisher', + id: 'opfPublisher', + name: 'opfPublisher', hierarchical: false, }), ]; @@ -102,7 +102,7 @@ describe('DsDynamicLookupRelationModalComponent', () => { relationshipType: 'isAuthorOfPublication', nameVariants: true, searchConfiguration: 'personConfig', - externalSources: ['orcidV2', 'sherpaPublisher'], + externalSources: ['orcidV2', 'opfPublisher'], }); nameVariant = 'Doe, J.'; metadataField = 'dc.contributor.author'; diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 40711c188d1..a5a818a78dc 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -92,18 +92,18 @@ export class ConcatFieldParser extends FieldParser { concatGroup.disabled = input1ModelConfig.readOnly; if (isNotEmpty(this.firstPlaceholder)) { - input1ModelConfig.placeholder = this.firstPlaceholder; + input1ModelConfig.label = this.firstPlaceholder; } if (isNotEmpty(this.secondPlaceholder)) { - input2ModelConfig.placeholder = this.secondPlaceholder; + input2ModelConfig.label = this.secondPlaceholder; } // Split placeholder if is like 'placeholder1/placeholder2' const placeholder = this.configData.label.split('/'); if (placeholder.length === 2) { - input1ModelConfig.placeholder = placeholder[0]; - input2ModelConfig.placeholder = placeholder[1]; + input1ModelConfig.label = placeholder[0]; + input2ModelConfig.label = placeholder[1]; } const model1 = new DsDynamicInputModel(input1ModelConfig, clsInput); diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts index ec2172523fe..5a5c80987b3 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -67,4 +67,11 @@ describe('DateFieldParser test suite', () => { expect(fieldModel.value).toEqual(expectedValue); }); + + it('should skip setting the placeholder', () => { + const parser = new DateFieldParser(submissionId, field, initFormValues, parserOptions, translateService); + const fieldModel = parser.parse(); + + expect(fieldModel.placeholder).toBeNull(); + }); }); diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts index 8104568b1aa..d086bd11ac6 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -11,8 +11,8 @@ export class DateFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any { let malformedDate = false; - const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, false, true); - inputDateModelConfig.legend = this.configData.label; + const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, label, true); + inputDateModelConfig.legend = this.configData.repeatable ? null : this.configData.label; inputDateModelConfig.disabled = inputDateModelConfig.readOnly; inputDateModelConfig.toggleIcon = 'fas fa-calendar'; this.setValues(inputDateModelConfig as any, fieldValue); diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 1f510fb6e77..2c47a4a30c5 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -306,7 +306,8 @@ export abstract class FieldParser { if (hint) { controlModel.hint = this.configData.hints || ' '; } - controlModel.placeholder = this.configData.label; + + controlModel.additional = { ...controlModel.additional, ariaLabel: this.configData.label }; if (this.configData.mandatory && setErrors) { this.markAsRequired(controlModel); diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index dc5bbe91d71..00cf05dc3c4 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -12,14 +12,13 @@ (dfFocus)="onFocus($event)" (ngbEvent)="onCustomEvent($event)"> -
@@ -27,15 +26,14 @@
- +
diff --git a/src/app/shared/form/number-picker/number-picker.component.html b/src/app/shared/form/number-picker/number-picker.component.html index 9b9d038e868..e704e278c6a 100644 --- a/src/app/shared/form/number-picker/number-picker.component.html +++ b/src/app/shared/form/number-picker/number-picker.component.html @@ -1,38 +1,45 @@ -
- - + + +
diff --git a/src/app/shared/form/number-picker/number-picker.component.scss b/src/app/shared/form/number-picker/number-picker.component.scss index 94f7f38ef01..95ad3e8e3d8 100644 --- a/src/app/shared/form/number-picker/number-picker.component.scss +++ b/src/app/shared/form/number-picker/number-picker.component.scss @@ -4,24 +4,32 @@ .chevron::before { border-style: solid; - border-width: 0.29em 0.29em 0 0; + border-width: 0.19em 0.19em 0 0; content: ''; display: inline-block; height: 0.69em; - left: 0.05em; position: relative; - top: 0.15em; + top: -0.15rem; transform: rotate(-45deg); vertical-align: middle; width: 0.71em; } .chevron.bottom:before { - top: -.3em; + top: -.45em; transform: rotate(135deg); } -input { - max-width: 80px !important; +.btn-date { + max-height: 1.1rem; + padding: 0; +} + +.four-digits { + width: 90px; +} + +.two-digits { + width: 80px; } .btn-link-focus { 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 ef35e1ee796..672a68e8016 100644 --- a/src/app/shared/form/number-picker/number-picker.component.ts +++ b/src/app/shared/form/number-picker/number-picker.component.ts @@ -50,6 +50,7 @@ export class NumberPickerComponent implements OnChanges, OnInit, ControlValueAcc @Input() disabled: boolean; @Input() invalid: boolean; @Input() value: number; + @Input() widthClass: 'four-digits' | 'two-digits' | undefined; @Output() selected = new EventEmitter(); @Output() remove = new EventEmitter(); 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 6da6a33cd4b..93f44d6abec 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html @@ -123,3 +123,20 @@

+ @if (showPreviousPage$ | async) { + + } + @if (showNextPage$ | async) { + + } +

+} diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts index b62dff59b05..7ddf51866f7 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts @@ -23,6 +23,7 @@ import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { Observable, + of, Subscription, } from 'rxjs'; import { @@ -172,6 +173,10 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges readonly AlertType = AlertType; + public showNextPage$: Observable; + + public showPreviousPage$: Observable; + /** * Initialize instance variables * @@ -275,6 +280,12 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges * Initialize the component, setting up the data to build the tree */ ngOnInit(): void { + + // Initialize observables to false when component loads + // Ensures pagination buttons are hidden on first load or after navigation + this.showNextPage$ = of(false); + this.showPreviousPage$ = of(false); + this.subs.push( this.vocabularyService.findVocabularyById(this.vocabularyOptions.name).pipe( // Retrieve the configured preloadLevel from REST @@ -344,6 +355,17 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges * Search for a vocabulary entry by query */ search() { + + // Reassign observables after performing each new search + // Updates pagination button visibility based on available pages + this.showNextPage$ = this.vocabularyTreeviewService.showNextPageSubject + ? this.vocabularyTreeviewService.showNextPageSubject.asObservable() + : of(false); + + this.showPreviousPage$ = this.vocabularyTreeviewService.showPreviousPageSubject + ? this.vocabularyTreeviewService.showPreviousPageSubject.asObservable() + : of(false); + if (isNotEmpty(this.searchText)) { if (isEmpty(this.storedNodeMap)) { this.storedNodeMap = this.nodeMap; @@ -353,6 +375,30 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges } } + /** + * Loads the next page of vocabulary search results. + * Increments the current page in the service and re-triggers the query with the same search term and selection. + */ + loadNextPage(): void { + const svc = this.vocabularyTreeviewService; + + if (svc.currentPage < svc.totalPages) { + svc.searchByQueryAndPage(svc.queryInProgress, [], svc.currentPage + 1); + } + } + + /** + * Loads the previous page of vocabulary search results. + * Decrements the current page in the service and re-triggers the query with the same search term and selection. + */ + loadPreviousPage(): void { + const svc = this.vocabularyTreeviewService; + + if (svc.currentPage > 1) { + svc.searchByQueryAndPage(svc.queryInProgress, [], svc.currentPage - 1); + } + } + /** * Check if search box contains any text */ @@ -379,6 +425,9 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges if (this.searchInput) { this.searchInput.nativeElement.focus(); } + + this.showNextPage$ = of(false); + this.showPreviousPage$ = of(false); } add() { diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts index 7e84c82edfd..722015da951 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts @@ -6,22 +6,19 @@ import { of as observableOf, } from 'rxjs'; import { - catchError, - finalize, map, merge, mergeMap, scan, - timeout, + tap, } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; import { - buildPaginatedList, - PaginatedList, -} from '../../../core/data/paginated-list.model'; -import { - getFirstCompletedRemoteData, + getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteListPayload, } from '../../../core/shared/operators'; import { PageInfo } from '../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; @@ -29,7 +26,6 @@ import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/mod import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; import { - hasValue, isEmpty, isNotEmpty, } from '../../empty.util'; @@ -97,6 +93,12 @@ export class VocabularyTreeviewService { */ private hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.loading.next(false)); + public currentPage = 1; + public totalPages = 1; + public queryInProgress = ''; + public showNextPageSubject = new BehaviorSubject(false); + public showPreviousPageSubject = new BehaviorSubject(false); + /** * Initialize instance variables * @@ -204,10 +206,28 @@ export class VocabularyTreeviewService { } /** - * Perform a search operation by query + * Initiates a vocabulary search using the provided query term and selection, starting from the first page. + * + * @param query - The text input to search for within the vocabulary. + * @param selectedItems - Currently selected vocabulary item IDs to retain in the result. */ searchByQuery(query: string, selectedItems: string[]) { + this.searchByQueryAndPage(query, selectedItems, 1); + } + + /** + * Executes a paginated vocabulary search with the given query, selection, and page number. + * Updates pagination state, loading indicators, and triggers the vocabulary tree rebuild. + * + * @param query - The search term to filter vocabulary entries. + * @param selectedItems - IDs of items currently selected in the tree. + * @param page - The page number to fetch (1-based index). + */ + searchByQueryAndPage(query: string, selectedItems: string[], page: number = 1) { this.loading.next(true); + this.queryInProgress = query; + this.currentPage = page; + if (isEmpty(this.storedNodes)) { this.storedNodes = this.dataChange.value; this.storedNodeMap = this.nodeMap; @@ -215,16 +235,26 @@ export class VocabularyTreeviewService { this.nodeMap = new Map(); this.dataChange.next([]); - this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, new PageInfo()).pipe( - timeout({ each: 15000 }), - getFirstCompletedRemoteData(), - map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload.page : []), - mergeMap((result: VocabularyEntry[]) => (result.length > 0) ? result : observableOf(null)), + const pageInfo = new PageInfo({ + elementsPerPage: 20, + currentPage: page, + totalElements: 0, + totalPages: 0, + }); + + this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, pageInfo).pipe( + getFirstSucceededRemoteData(), + tap((rd: RemoteData>) => { + this.totalPages = rd.payload.pageInfo.totalPages; + this.showPreviousPageSubject.next(rd.payload.pageInfo.currentPage > 1); + this.showNextPageSubject.next(rd.payload.pageInfo.currentPage < this.totalPages); + }), + getFirstSucceededRemoteListPayload(), + mergeMap((result: VocabularyEntry[]) => result.length > 0 ? result : observableOf(null)), mergeMap((entry: VocabularyEntry) => - isNotEmpty(entry) ? this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( - getFirstCompletedRemoteData(), - map((entryRd) => (entryRd.hasSucceeded && hasValue(entryRd.payload)) ? entryRd.payload : null), - ) : observableOf(null), + this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( + getFirstSucceededRemoteDataPayload(), + ), ), mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems)), scan((acc: TreeviewNode[], value: TreeviewNode) => { @@ -234,11 +264,10 @@ export class VocabularyTreeviewService { return [...acc, value]; } }, []), - catchError(() => observableOf([] as TreeviewNode[])), - finalize(() => this.loading.next(false)), merge(this.hideSearchingWhenUnsubscribed$), ).subscribe((nodes: TreeviewNode[]) => { this.dataChange.next(nodes); + this.loading.next(false); }); } @@ -296,7 +325,7 @@ export class VocabularyTreeviewService { private getNodeHierarchyById(id: string, selectedItems: string[]): Observable { return this.getById(id).pipe( mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems,[], false)), - map((node: TreeviewNode) => isNotEmpty(node) ? this.getNodeHierarchyIds(node, selectedItems) : []), + map((node: TreeviewNode) => this.getNodeHierarchyIds(node, selectedItems)), ); } @@ -329,10 +358,7 @@ export class VocabularyTreeviewService { */ private getById(entryId: string): Observable { return this.vocabularyService.findEntryDetailById(entryId, this.vocabularyName).pipe( - timeout({ each: 15000 }), - getFirstCompletedRemoteData(), - map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : null), - catchError(() => observableOf(null)), + getFirstSucceededRemoteDataPayload(), ); } @@ -344,11 +370,7 @@ export class VocabularyTreeviewService { */ private retrieveTopNodes(pageInfo: PageInfo, nodes: TreeviewNode[], selectedItems: string[]): void { this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe( - timeout({ each: 15000 }), - getFirstCompletedRemoteData(), - map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), - catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))), - finalize(() => this.loading.next(false)), + getFirstSucceededRemoteDataPayload(), ).subscribe((list: PaginatedList) => { this.vocabularyService.clearSearchTopRequests(); const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry, selectedItems)); @@ -363,6 +385,7 @@ export class VocabularyTreeviewService { loadMoreNode.updatePageInfo(newPageInfo); nodes.push(loadMoreNode); } + this.loading.next(false); // Notify the change. this.dataChange.next(nodes); }); diff --git a/src/app/shared/menu/menu.reducer.spec.ts b/src/app/shared/menu/menu.reducer.spec.ts index da837f20629..32a8cc8a465 100644 --- a/src/app/shared/menu/menu.reducer.spec.ts +++ b/src/app/shared/menu/menu.reducer.spec.ts @@ -388,6 +388,13 @@ describe('menusReducer', () => { expect(newState[menuID].sectionToSubsectionIndex[parentID]).not.toContain(childID); }); + it('should not throw an error when trying to remove an already removed section using the REMOVE_SECTION action', () => { + const state = dummyState; + const action = new RemoveMenuSectionAction(menuID, 'non-existing-id'); + const newState = menusReducer(state, action); + expect(newState).toEqual(dummyState); + }); + it('should set active to true for the correct menu section in response to the ACTIVATE_SECTION action', () => { dummyState[menuID].sections[topSectionID].active = false; const state = dummyState; diff --git a/src/app/shared/menu/menu.reducer.ts b/src/app/shared/menu/menu.reducer.ts index e0a6dc7d8b3..d59ca439f6c 100644 --- a/src/app/shared/menu/menu.reducer.ts +++ b/src/app/shared/menu/menu.reducer.ts @@ -1,4 +1,7 @@ -import { hasValue } from '../empty.util'; +import { + hasNoValue, + hasValue, +} from '../empty.util'; import { initialMenusState } from './initial-menus-state'; import { ActivateMenuSectionAction, @@ -148,11 +151,14 @@ function removeSection(state: MenusState, action: RemoveMenuSectionAction) { /** * Remove a section from the index of a certain menu * @param {MenusState} state The initial state - * @param {MenuSection} action The MenuSection of which the ID should be removed from the index - * @param {MenuID} action The Menu ID to which the section belonged + * @param {MenuSection} section The MenuSection of which the ID should be removed from the index + * @param {MenuID} menuID The Menu ID to which the section belonged * @returns {MenusState} The new reduced state */ function removeFromIndex(state: MenusState, section: MenuSection, menuID: MenuID) { + if (hasNoValue(section)) { + return state; + } const sectionID = section.id; const parentID = section.parentID; if (hasValue(parentID)) { diff --git a/src/app/shared/mocks/section-opf-policies.service.mock.ts b/src/app/shared/mocks/section-opf-policies.service.mock.ts new file mode 100644 index 00000000000..ff03c3d191e --- /dev/null +++ b/src/app/shared/mocks/section-opf-policies.service.mock.ts @@ -0,0 +1,99 @@ +import { WorkspaceitemSectionJiscOpfPoliciesObject } from 'src/app/core/submission/models/workspaceitem-section-opf-policies.model'; + +export const OpfDataResponse = { + 'id': 'opfPolicies', + 'retrievalTime': '2022-04-20T09:44:39.870+00:00', + 'opfResponse': + { + 'error': false, + 'message': null, + 'metadata': { + 'id': 23803, + 'uri': 'http://v2.sherpa.ac.uk/id/publication/23803', + 'dateCreated': '2012-11-20 14:51:52', + 'dateModified': '2020-03-06 11:25:54', + 'inDOAJ': false, + 'publiclyVisible': true, + }, + 'journals': [{ + 'titles': ['The Lancet', 'Lancet'], + 'url': 'http://www.thelancet.com/journals/lancet/issue/current', + 'issns': ['0140-6736', '1474-547X'], + 'romeoPub': 'Elsevier: The Lancet', + 'zetoPub': 'Elsevier: The Lancet', + 'publisher': { + 'name': 'Elsevier', + 'relationshipType': null, + 'country': null, + 'uri': 'http://www.elsevier.com/', + 'identifier': null, + 'publicationCount': 0, + 'paidAccessDescription': 'Open access', + 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access', + }, + 'publishers': [{ + 'name': 'Elsevier', + 'relationshipType': null, + 'country': null, + 'uri': 'http://www.elsevier.com/', + 'identifier': null, + 'publicationCount': 0, + 'paidAccessDescription': 'Open access', + 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access', + }], + 'policies': [{ + 'id': 0, + 'openAccessPermitted': false, + 'uri': null, + 'internalMoniker': 'Lancet', + 'permittedVersions': [{ + 'articleVersion': 'submitted', + 'option': 1, + 'conditions': ['Upon publication publisher copyright and source must be acknowledged', 'Upon publication must link to publisher version'], + 'prerequisites': [], + 'locations': ['Author\'s Homepage', 'Preprint Repository'], + 'licenses': [], + 'embargo': null, + }, { + 'articleVersion': 'accepted', + 'option': 1, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': [], + 'locations': ['Author\'s Homepage', 'Institutional Website'], + 'licenses': ['CC BY-NC-ND'], + 'embargo': null, + }, { + 'articleVersion': 'accepted', + 'option': 2, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': ['If Required by Funder'], + 'locations': ['Non-Commercial Repository'], + 'licenses': ['CC BY-NC-ND'], + 'embargo': { amount: 6, units: 'Months' }, + }, { + 'articleVersion': 'accepted', + 'option': 3, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': [], + 'locations': ['Non-Commercial Repository'], + 'licenses': [], + 'embargo': null, + }], + 'urls': { + 'http://download.thelancet.com/flatcontentassets/authors/lancet-information-for-authors.pdf': 'Guidelines for Authors', + 'http://www.thelancet.com/journals/lancet/article/PIIS0140-6736%2813%2960720-5/fulltext': 'The Lancet journals welcome a new open access policy', + 'http://www.thelancet.com/lancet-information-for-authors/after-publication': 'What happens after publication?', + 'http://www.thelancet.com/lancet/information-for-authors/disclosure-of-results': 'Disclosure of results before publication', + 'https://www.elsevier.com/__data/assets/pdf_file/0005/78476/external-embargo-list.pdf': 'Journal Embargo Period List', + 'https://www.elsevier.com/__data/assets/pdf_file/0011/78473/UK-Embargo-Periods.pdf': 'Journal Embargo List for UK Authors', + }, + 'openAccessProhibited': false, + 'publicationCount': 0, + 'preArchiving': 'can', + 'postArchiving': 'can', + 'pubArchiving': 'cannot', + }], + 'inDOAJ': false, + }], + }, +} as WorkspaceitemSectionJiscOpfPoliciesObject; diff --git a/src/app/shared/mocks/section-sherpa-policies.service.mock.ts b/src/app/shared/mocks/section-sherpa-policies.service.mock.ts deleted file mode 100644 index f379af2568d..00000000000 --- a/src/app/shared/mocks/section-sherpa-policies.service.mock.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { WorkspaceitemSectionSherpaPoliciesObject } from '../../core/submission/models/workspaceitem-section-sherpa-policies.model'; - -export const SherpaDataResponse = { - 'id': 'sherpaPolicies', - 'retrievalTime': '2022-04-20T09:44:39.870+00:00', - 'sherpaResponse': - { - 'error': false, - 'message': null, - 'metadata': { - 'id': 23803, - 'uri': 'http://v2.sherpa.ac.uk/id/publication/23803', - 'dateCreated': '2012-11-20 14:51:52', - 'dateModified': '2020-03-06 11:25:54', - 'inDOAJ': false, - 'publiclyVisible': true, - }, - 'journals': [{ - 'titles': ['The Lancet', 'Lancet'], - 'url': 'http://www.thelancet.com/journals/lancet/issue/current', - 'issns': ['0140-6736', '1474-547X'], - 'romeoPub': 'Elsevier: The Lancet', - 'zetoPub': 'Elsevier: The Lancet', - 'publisher': { - 'name': 'Elsevier', - 'relationshipType': null, - 'country': null, - 'uri': 'http://www.elsevier.com/', - 'identifier': null, - 'publicationCount': 0, - 'paidAccessDescription': 'Open access', - 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access', - }, - 'publishers': [{ - 'name': 'Elsevier', - 'relationshipType': null, - 'country': null, - 'uri': 'http://www.elsevier.com/', - 'identifier': null, - 'publicationCount': 0, - 'paidAccessDescription': 'Open access', - 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access', - }], - 'policies': [{ - 'id': 0, - 'openAccessPermitted': false, - 'uri': null, - 'internalMoniker': 'Lancet', - 'permittedVersions': [{ - 'articleVersion': 'submitted', - 'option': 1, - 'conditions': ['Upon publication publisher copyright and source must be acknowledged', 'Upon publication must link to publisher version'], - 'prerequisites': [], - 'locations': ['Author\'s Homepage', 'Preprint Repository'], - 'licenses': [], - 'embargo': null, - }, { - 'articleVersion': 'accepted', - 'option': 1, - 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], - 'prerequisites': [], - 'locations': ['Author\'s Homepage', 'Institutional Website'], - 'licenses': ['CC BY-NC-ND'], - 'embargo': null, - }, { - 'articleVersion': 'accepted', - 'option': 2, - 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], - 'prerequisites': ['If Required by Funder'], - 'locations': ['Non-Commercial Repository'], - 'licenses': ['CC BY-NC-ND'], - 'embargo': { amount: 6, units: 'Months' }, - }, { - 'articleVersion': 'accepted', - 'option': 3, - 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], - 'prerequisites': [], - 'locations': ['Non-Commercial Repository'], - 'licenses': [], - 'embargo': null, - }], - 'urls': { - 'http://download.thelancet.com/flatcontentassets/authors/lancet-information-for-authors.pdf': 'Guidelines for Authors', - 'http://www.thelancet.com/journals/lancet/article/PIIS0140-6736%2813%2960720-5/fulltext': 'The Lancet journals welcome a new open access policy', - 'http://www.thelancet.com/lancet-information-for-authors/after-publication': 'What happens after publication?', - 'http://www.thelancet.com/lancet/information-for-authors/disclosure-of-results': 'Disclosure of results before publication', - 'https://www.elsevier.com/__data/assets/pdf_file/0005/78476/external-embargo-list.pdf': 'Journal Embargo Period List', - 'https://www.elsevier.com/__data/assets/pdf_file/0011/78473/UK-Embargo-Periods.pdf': 'Journal Embargo List for UK Authors', - }, - 'openAccessProhibited': false, - 'publicationCount': 0, - 'preArchiving': 'can', - 'postArchiving': 'can', - 'pubArchiving': 'cannot', - }], - 'inDOAJ': false, - }], - }, -} as WorkspaceitemSectionSherpaPoliciesObject; diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html index b164767c238..3496f380a06 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html @@ -1,5 +1,5 @@
diff --git a/src/app/shared/search/advanced-search/advanced-search.component.ts b/src/app/shared/search/advanced-search/advanced-search.component.ts index 99eaa23a0d7..b13775521c5 100644 --- a/src/app/shared/search/advanced-search/advanced-search.component.ts +++ b/src/app/shared/search/advanced-search/advanced-search.component.ts @@ -38,6 +38,7 @@ import { } from '../../empty.util'; import { FilterInputSuggestionsComponent } from '../../input-suggestions/filter-suggestions/filter-input-suggestions.component'; import { InputSuggestion } from '../../input-suggestions/input-suggestions.model'; +import { currentPath } from '../../utils/route.utils'; import { FilterType } from '../models/filter-type.model'; import { SearchFilterConfig } from '../models/search-filter-config.model'; @@ -71,6 +72,11 @@ export class AdvancedSearchComponent implements OnInit, OnDestroy { */ @Input() filtersConfig: SearchFilterConfig[]; + /** + * True when the search component should show results on the current page + */ + @Input() inPlaceSearch: boolean; + /** * The current search scope */ @@ -136,11 +142,21 @@ export class AdvancedSearchComponent implements OnInit, OnDestroy { } } + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + getSearchLink(): string { + if (this.inPlaceSearch) { + return currentPath(this.router); + } + return this.searchService.getSearchLink(); + } + applyFilter(): void { if (isNotEmpty(this.currentValue)) { this.searchFilterService.minimizeAll(); this.subs.push(this.searchConfigurationService.selectNewAppliedFilterParams(this.currentFilter, this.currentValue.trim(), this.currentOperator).pipe(take(1)).subscribe((params: Params) => { - void this.router.navigate([this.searchService.getSearchLink()], { + void this.router.navigate([this.getSearchLink()], { queryParams: params, }); this.currentValue = ''; diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.scss b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.scss new file mode 100644 index 00000000000..5d4e87f30f6 --- /dev/null +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts index 5c201eecfee..40f486f3e30 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -12,6 +12,7 @@ import { renderFilterType } from '../search-filter-type-decorator'; @Component({ selector: 'ds-search-facet-filter-wrapper', + styleUrls: ['./search-facet-filter-wrapper.component.scss'], templateUrl: '../../../../abstract-component-loader/abstract-component-loader.component.html', standalone: true, imports: [ diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts index 34427809199..67b01390ac9 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts @@ -132,7 +132,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i take(1), ).subscribe((params: Params) => { void this.router.navigate( - [this.searchService.getSearchLink()], + [this.getSearchLink()], { queryParams: params, }, diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.html b/src/app/shared/search/search-sidebar/search-sidebar.component.html index 43fdc993919..c0b68edf7a3 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.html +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.html @@ -27,6 +27,7 @@ {{ 'search.switch-configuration.title' | translate [compareWith]="compare" [(ngModel)]="selectedOption" (change)="onSelect()"> - diff --git a/src/app/shared/sidebar/page-with-sidebar.component.scss b/src/app/shared/sidebar/page-with-sidebar.component.scss index 4949694313f..b856fef81d7 100644 --- a/src/app/shared/sidebar/page-with-sidebar.component.scss +++ b/src/app/shared/sidebar/page-with-sidebar.component.scss @@ -34,6 +34,8 @@ position: absolute; top: 0; width: 100%; + height: 100%; + overflow: auto; } } } diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 6d0ba38b5b7..56a09a9243d 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -3,6 +3,7 @@ import { Inject, Injectable, Injector, + Optional, } from '@angular/core'; import { ActivatedRouteSnapshot, @@ -39,6 +40,7 @@ import { ThemeConfig, } from '../../../config/theme.config'; import { environment } from '../../../environments/environment'; +import { HashedFileMapping } from '../../../modules/dynamic-hash/hashed-file-mapping'; import { LinkService } from '../../core/cache/builders/link.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -71,6 +73,7 @@ import { } from './theme.model'; import { ThemeState } from './theme.reducer'; + export const themeStateSelector = createFeatureSelector('theme'); export const currentThemeSelector = createSelector( @@ -103,6 +106,7 @@ export class ThemeService { @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig, private router: Router, @Inject(DOCUMENT) private document: any, + @Optional() private hashedFileMapping: HashedFileMapping, ) { // Create objects from the theme configs in the environment file this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig, injector)); @@ -225,10 +229,14 @@ export class ThemeService { // automatically updated if we add nodes later const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css')); const link = this.document.createElement('link'); + const themeCSS = `${encodeURIComponent(themeName)}-theme.css`; link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('class', 'theme-css'); - link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`); + link.setAttribute( + 'href', + this.hashedFileMapping?.resolve(themeCSS) ?? themeCSS, + ); // wait for the new css to download before removing the old one to prevent a // flash of unstyled content link.onload = () => { diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts index 0c49a56405e..39851d6c589 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts @@ -125,24 +125,10 @@ export class TruncatablePartComponent implements AfterViewChecked, OnInit, OnDes public truncateElement() { if (this.showToggle) { const entry = this.content.nativeElement; - if (entry.scrollHeight > entry.offsetHeight) { - if (entry.children.length > 0) { - if (entry.children[entry.children.length - 1].offsetHeight > entry.offsetHeight) { - entry.classList.add('truncated'); - entry.classList.remove('removeFaded'); - } else { - entry.classList.remove('truncated'); - entry.classList.add('removeFaded'); - } - } else { - if (entry.innerText.length > 0) { - entry.classList.add('truncated'); - entry.classList.remove('removeFaded'); - } else { - entry.classList.remove('truncated'); - entry.classList.add('removeFaded'); - } - } + const isOverflowing = entry.scrollHeight > entry.clientHeight; + if (isOverflowing) { + entry.classList.add('truncated'); + entry.classList.remove('removeFaded'); } else { entry.classList.remove('truncated'); entry.classList.add('removeFaded'); diff --git a/src/app/shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component.ts b/src/app/shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component.ts index e937b1ea0fb..6c12b7a2dda 100644 --- a/src/app/shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component.ts +++ b/src/app/shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component.ts @@ -90,6 +90,11 @@ export class FileDropzoneNoUploaderComponent implements OnInit { @HostListener('window:dragover', ['$event']) onDragOver(event: DragEvent) { + // Only show drop area when dragging files or event is manually triggered + const hasFiles = event.dataTransfer?.types ? Array.from(event.dataTransfer.types).includes('Files') : true; + if (!hasFiles) { + return; + } // Show drop area on the page event.preventDefault(); event.stopPropagation(); diff --git a/src/app/shared/upload/uploader/uploader.component.spec.ts b/src/app/shared/upload/uploader/uploader.component.spec.ts index b715dd26c40..6db7c4a0504 100644 --- a/src/app/shared/upload/uploader/uploader.component.spec.ts +++ b/src/app/shared/upload/uploader/uploader.component.spec.ts @@ -16,13 +16,15 @@ import { FileUploadModule } from 'ng2-file-upload'; import { DragService } from '../../../core/drag.service'; import { CookieService } from '../../../core/services/cookie.service'; +import { LiveRegionService } from '../../live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../live-region/live-region.service.stub'; import { CookieServiceMock } from '../../mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../mocks/http-xsrf-token-extractor.mock'; import { createTestComponent } from '../../testing/utils.test'; import { UploaderComponent } from './uploader.component'; import { UploaderOptions } from './uploader-options.model'; -describe('Chips component', () => { +describe('Uploader component', () => { let testComp: TestComponent; let testFixture: ComponentFixture; @@ -44,6 +46,7 @@ describe('Chips component', () => { DragService, { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }); diff --git a/src/app/shared/upload/uploader/uploader.component.ts b/src/app/shared/upload/uploader/uploader.component.ts index e3532d1456b..cf585c8f36e 100644 --- a/src/app/shared/upload/uploader/uploader.component.ts +++ b/src/app/shared/upload/uploader/uploader.component.ts @@ -15,6 +15,7 @@ import { import { TranslateModule } from '@ngx-translate/core'; import uniqueId from 'lodash/uniqueId'; import { + FileItem, FileUploader, FileUploadModule, } from 'ng2-file-upload'; @@ -33,8 +34,8 @@ import { isNotEmpty, isUndefined, } from '../../empty.util'; +import { LiveRegionService } from '../../live-region/live-region.service'; import { UploaderCompleteEvent } from './uploader-complete-event.model'; -import { UploaderError } from './uploader-error.model'; import { UploaderOptions } from './uploader-options.model'; import { UploaderProperties } from './uploader-properties.model'; @@ -103,7 +104,7 @@ export class UploaderComponent implements OnInit, AfterViewInit { /** * The function to call on error occurred */ - @Output() onUploadError: EventEmitter = new EventEmitter(); + @Output() onUploadError: EventEmitter = new EventEmitter(); /** * The function to call when a file is selected @@ -115,10 +116,26 @@ export class UploaderComponent implements OnInit, AfterViewInit { public isOverBaseDropZone = observableOf(false); public isOverDocumentDropZone = observableOf(false); + /** + * Set of progress values that have been announced to screen readers + */ + private announcedProgress: Set = new Set(); + + /** + * The uuid of the last progress message announced to screen readers + * @private + */ + private lastProgressMessageUuid: string; + @HostListener('window:dragover', ['$event']) onDragOver(event: any) { if (this.enableDragOverDocument && this.dragService.isAllowedDragOverPage()) { + // Only show drop area when dragging files or event is manually triggered + const hasFiles = event.dataTransfer?.types ? Array.from(event.dataTransfer.types).includes('Files') : true; + if (!hasFiles) { + return; + } // Show drop area on the page event.preventDefault(); if ((event.target as any).tagName !== 'HTML') { @@ -132,6 +149,7 @@ export class UploaderComponent implements OnInit, AfterViewInit { private dragService: DragService, private tokenExtractor: HttpXsrfTokenExtractor, private cookieService: CookieService, + private liveRegionService: LiveRegionService, ) { } @@ -221,7 +239,28 @@ export class UploaderComponent implements OnInit, AfterViewInit { this.uploader.cancelAll(); }; this.uploader.onProgressAll = () => this.onProgress(); - this.uploader.onProgressItem = () => this.onProgress(); + // Live region service setup + this.liveRegionService.setMessageTimeOutMs(1500); + this.liveRegionService.clear(); + this.uploader.onProgressItem = (fileItem: FileItem, progress: any) => { + this.announceProgress(progress); + this.onProgress(); + }; + } + + /** + * Announce the progress of the upload to screen readers + * @param progress + */ + private announceProgress(progress: any) { + if (!this.announcedProgress.has(progress)) { + this.announcedProgress.add(progress); + const message = progress + '%'; + if (this.lastProgressMessageUuid) { + this.liveRegionService.clearMessageByUUID(this.lastProgressMessageUuid); + } + this.lastProgressMessageUuid = this.liveRegionService.addMessage(message); + } } /** diff --git a/src/app/shared/utils/markdown.directive.spec.ts b/src/app/shared/utils/markdown.directive.spec.ts index b984543c40d..1fe3f7289a8 100644 --- a/src/app/shared/utils/markdown.directive.spec.ts +++ b/src/app/shared/utils/markdown.directive.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Component, DebugElement, @@ -20,6 +21,14 @@ import { MarkdownDirective } from './markdown.directive'; }) class TestComponent {} +@Component({ + template: `
`, + imports: [ + MarkdownDirective, + ], +}) +class LinkifyTestComponent {} + describe('MarkdownDirective', () => { let fixture: ComponentFixture; @@ -54,10 +63,9 @@ describe('MarkdownDirective sanitization with markdown disabled', () => { }).compileComponents(); fixture = TestBed.createComponent(TestComponent); divEl = fixture.debugElement.query(By.css('div')); - }); - it('should sanitize the script element out of innerHTML (markdown disabled)',() => { + it('should sanitize the script element out of innerHTML (markdown disabled)', () => { fixture.detectChanges(); divEl = fixture.debugElement.query(By.css('div')); expect(divEl.nativeElement.innerHTML).toEqual('test'); @@ -79,13 +87,37 @@ describe('MarkdownDirective sanitization with markdown enabled', () => { }).compileComponents(); fixture = TestBed.createComponent(TestComponent); divEl = fixture.debugElement.query(By.css('div')); - }); - it('should sanitize the script element out of innerHTML (markdown enabled)',() => { + it('should sanitize the script element out of innerHTML (markdown enabled)', () => { fixture.detectChanges(); divEl = fixture.debugElement.query(By.css('div')); expect(divEl.nativeElement.innerHTML).toEqual('test'); }); }); + +describe('MarkdownDirective linkify with markdown enabled', () => { + let fixture: ComponentFixture; + let divEl: DebugElement; + // Enable markdown + environment.markdown.enabled = true; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: MathService, useClass: MockMathService }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(LinkifyTestComponent); + divEl = fixture.debugElement.query(By.css('div')); + }); + + it('should not convert words with dots (e.g. demandés.es) to links (#2789)', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + divEl = fixture.debugElement.query(By.css('div')); + expect(divEl.nativeElement.innerHTML).not.toContain(' -
+
+
-
+
{{'submission.general.info.saved' | translate}} @@ -32,7 +32,7 @@
{{'submission.general.info.depositing' | translate}}
-
+
@@ -13,7 +13,7 @@ {{version.embargo?.units[0]}} {{ - 'submission.sections.sherpa.publisher.policy.noembargo' | translate }} + 'submission.sections.jisc.publisher.policy.noembargo' | translate }} @@ -24,7 +24,7 @@ {{ - 'submission.sections.sherpa.publisher.policy.nolocation' | translate }} + 'submission.sections.jisc.publisher.policy.nolocation' | translate }}
@@ -39,20 +39,20 @@

{{ - 'submission.sections.sherpa.publisher.policy.embargo' | translate }}

+ 'submission.sections.jisc.publisher.policy.embargo' | translate }}

{{version.embargo.amount}} {{version.embargo.units}}

-

{{ 'submission.sections.sherpa.publisher.policy.noembargo' | translate }}

+

{{ 'submission.sections.jisc.publisher.policy.noembargo' | translate }}

{{ - 'submission.sections.sherpa.publisher.policy.license' | translate }}

+ 'submission.sections.jisc.publisher.policy.license' | translate }}

{{license}}

@@ -61,7 +61,7 @@

{{ - 'submission.sections.sherpa.publisher.policy.prerequisites' | translate }}

+ 'submission.sections.jisc.publisher.policy.prerequisites' | translate }}

{{prerequisite}}

@@ -70,7 +70,7 @@

{{ - 'submission.sections.sherpa.publisher.policy.location' | translate }}

+ 'submission.sections.jisc.publisher.policy.location' | translate }}

{{location}}

@@ -79,7 +79,7 @@

{{ - 'submission.sections.sherpa.publisher.policy.conditions' | translate }}

+ 'submission.sections.jisc.publisher.policy.conditions' | translate }}

{{condition}}

@@ -87,4 +87,4 @@
-
\ No newline at end of file +
diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.scss b/src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.scss similarity index 100% rename from src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.scss rename to src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.scss diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts b/src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.spec.ts similarity index 79% rename from src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts rename to src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.spec.ts index 2abe8e02ec6..ba6cce143f2 100644 --- a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts +++ b/src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.spec.ts @@ -10,7 +10,7 @@ import { TranslateModule, } from '@ngx-translate/core'; -import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { OpfDataResponse } from '../../../../shared/mocks/section-opf-policies.service.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { ContentAccordionComponent } from './content-accordion.component'; @@ -40,7 +40,7 @@ describe('ContentAccordionComponent', () => { component = fixture.componentInstance; de = fixture.debugElement; component.isCollapsed = false; - component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[0]; + component.version = OpfDataResponse.opfResponse.journals[0].policies[0].permittedVersions[0]; fixture.detectChanges(); }); @@ -49,13 +49,13 @@ describe('ContentAccordionComponent', () => { }); it('should show 2 rows', () => { - component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[0]; + component.version = OpfDataResponse.opfResponse.journals[0].policies[0].permittedVersions[0]; fixture.detectChanges(); expect(de.queryAll(By.css('.row')).length).toEqual(2); }); it('should show 5 rows', () => { - component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[2]; + component.version = OpfDataResponse.opfResponse.journals[0].policies[0].permittedVersions[2]; fixture.detectChanges(); expect(de.queryAll(By.css('.row')).length).toEqual(5); }); diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts b/src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.ts similarity index 96% rename from src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts rename to src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.ts index 2fde4f37cd4..b866168eee9 100644 --- a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts +++ b/src/app/submission/sections/opf-policies/content-accordion/content-accordion.component.ts @@ -10,7 +10,7 @@ import { import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { PermittedVersions } from '../../../../core/submission/models/sherpa-policies-details.model'; +import { PermittedVersions } from '../../../../core/submission/models/opf-policies-details.model'; /** * This component represents a section that contains the inner accordions for the publisher policy versions. diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.html similarity index 69% rename from src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html rename to src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.html index 15dd4d72867..3a1ef9f973b 100644 --- a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html +++ b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.html @@ -1,7 +1,7 @@
-

{{ 'submission.sections.sherpa.record.information.id' | translate }}

+

{{ 'submission.sections.jisc.record.information.id' | translate }}

{{metadata.id}} @@ -10,7 +10,7 @@

-

{{ 'submission.sections.sherpa.record.information.date.created' | translate }}

+

{{ 'submission.sections.jisc.record.information.date.created' | translate }}

{{metadata.dateCreated | date: 'd MMMM Y H:mm:ss zzzz' }} @@ -19,7 +19,7 @@

-

{{ 'submission.sections.sherpa.record.information.date.modified' | translate }}

+

{{ 'submission.sections.jisc.record.information.date.modified' | translate }}

{{metadata.dateModified| date: 'd MMMM Y H:mm:ss zzzz' }} @@ -28,7 +28,7 @@

-

{{ 'submission.sections.sherpa.record.information.uri' | translate }}

+

{{ 'submission.sections.jisc.record.information.uri' | translate }}

@@ -36,4 +36,4 @@

-
\ No newline at end of file +
diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.scss b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.scss similarity index 100% rename from src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.scss rename to src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.scss diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.spec.ts similarity index 88% rename from src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts rename to src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.spec.ts index 5accbf3c5ff..0cb76003a22 100644 --- a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts +++ b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.spec.ts @@ -9,7 +9,7 @@ import { TranslateModule, } from '@ngx-translate/core'; -import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { OpfDataResponse } from '../../../../shared/mocks/section-opf-policies.service.mock'; import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; import { MetadataInformationComponent } from './metadata-information.component'; @@ -37,7 +37,7 @@ describe('MetadataInformationComponent', () => { fixture = TestBed.createComponent(MetadataInformationComponent); component = fixture.componentInstance; de = fixture.debugElement; - component.metadata = SherpaDataResponse.sherpaResponse.metadata; + component.metadata = OpfDataResponse.opfResponse.metadata; fixture.detectChanges(); }); diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.ts similarity index 87% rename from src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts rename to src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.ts index 3c30038a9ae..1a4bde85071 100644 --- a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts +++ b/src/app/submission/sections/opf-policies/metadata-information/metadata-information.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { Metadata } from '../../../../core/submission/models/sherpa-policies-details.model'; +import { Metadata } from '../../../../core/submission/models/opf-policies-details.model'; /** * This component represents a section that contains the matadata informations. diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.html similarity index 72% rename from src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html rename to src/app/submission/sections/opf-policies/publication-information/publication-information.component.html index 534b95397e9..407bb25e11c 100644 --- a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html +++ b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.html @@ -1,7 +1,7 @@
-

{{'submission.sections.sherpa.publication.information.title' | translate}}

+

{{'submission.sections.jisc.publication.information.title' | translate}}

{{title}} @@ -10,7 +10,7 @@

-

{{'submission.sections.sherpa.publication.information.issns' | translate}}

+

{{'submission.sections.jisc.publication.information.issns' | translate}}

{{issn}} @@ -19,7 +19,7 @@

-

{{'submission.sections.sherpa.publication.information.url' | translate}}

+

{{'submission.sections.jisc.publication.information.url' | translate}}

@@ -31,7 +31,7 @@

-

{{'submission.sections.sherpa.publication.information.publishers' | translate}}

+

{{'submission.sections.jisc.publication.information.publishers' | translate}}

@@ -43,7 +43,7 @@

-

{{'submission.sections.sherpa.publication.information.romeoPub' | translate}}

+

{{'submission.sections.jisc.publication.information.romeoPub' | translate}}

@@ -53,7 +53,7 @@

-

{{'submission.sections.sherpa.publication.information.zetoPub' | translate}}

+

{{'submission.sections.jisc.publication.information.zetoPub' | translate}}

@@ -61,4 +61,4 @@

-
\ No newline at end of file +
diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.scss b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.scss similarity index 100% rename from src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.scss rename to src/app/submission/sections/opf-policies/publication-information/publication-information.component.scss diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.spec.ts similarity index 88% rename from src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts rename to src/app/submission/sections/opf-policies/publication-information/publication-information.component.spec.ts index fae68cd8a48..72869d411eb 100644 --- a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts +++ b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.spec.ts @@ -9,7 +9,7 @@ import { TranslateModule, } from '@ngx-translate/core'; -import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { OpfDataResponse } from '../../../../shared/mocks/section-opf-policies.service.mock'; import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; import { PublicationInformationComponent } from './publication-information.component'; @@ -38,7 +38,7 @@ describe('PublicationInformationComponent', () => { fixture = TestBed.createComponent(PublicationInformationComponent); component = fixture.componentInstance; de = fixture.debugElement; - component.journal = SherpaDataResponse.sherpaResponse.journals[0]; + component.journal = OpfDataResponse.opfResponse.journals[0]; fixture.detectChanges(); }); diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.ts similarity index 87% rename from src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts rename to src/app/submission/sections/opf-policies/publication-information/publication-information.component.ts index 8f256700a07..866bbfa70be 100644 --- a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts +++ b/src/app/submission/sections/opf-policies/publication-information/publication-information.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { Journal } from '../../../../core/submission/models/sherpa-policies-details.model'; +import { Journal } from '../../../../core/submission/models/opf-policies-details.model'; /** * This component represents a section that contains the journal publication information. diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.html similarity index 84% rename from src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html rename to src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.html index 96e42202b3f..b77ed45030d 100644 --- a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html +++ b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.html @@ -4,7 +4,7 @@

- {{'submission.sections.sherpa.publisher.policy.more.information' | translate}} + {{'submission.sections.jisc.publisher.policy.more.information' | translate}}

  • @@ -13,4 +13,4 @@
-
\ No newline at end of file +
diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.scss b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.scss similarity index 100% rename from src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.scss rename to src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.scss diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.spec.ts similarity index 89% rename from src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts rename to src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.spec.ts index 773c416f1c8..e1b10bad83a 100644 --- a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts +++ b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.spec.ts @@ -9,7 +9,7 @@ import { TranslateModule, } from '@ngx-translate/core'; -import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { OpfDataResponse } from '../../../../shared/mocks/section-opf-policies.service.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { ContentAccordionComponent } from '../content-accordion/content-accordion.component'; import { PublisherPolicyComponent } from './publisher-policy.component'; @@ -43,7 +43,7 @@ describe('PublisherPolicyComponent', () => { fixture = TestBed.createComponent(PublisherPolicyComponent); component = fixture.componentInstance; de = fixture.debugElement; - component.policy = SherpaDataResponse.sherpaResponse.journals[0].policies[0]; + component.policy = OpfDataResponse.opfResponse.journals[0].policies[0]; fixture.detectChanges(); }); diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.ts similarity index 91% rename from src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts rename to src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.ts index 6852a32473a..3e7ebf68402 100644 --- a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts +++ b/src/app/submission/sections/opf-policies/publisher-policy/publisher-policy.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { Policy } from '../../../../core/submission/models/sherpa-policies-details.model'; +import { Policy } from '../../../../core/submission/models/opf-policies-details.model'; import { AlertType } from '../../../../shared/alert/alert-type'; import { ContentAccordionComponent } from '../content-accordion/content-accordion.component'; diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html b/src/app/submission/sections/opf-policies/section-opf-policies.component.html similarity index 75% rename from src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html rename to src/app/submission/sections/opf-policies/section-opf-policies.component.html index 4b636ee46eb..f7d76a5b616 100644 --- a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html +++ b/src/app/submission/sections/opf-policies/section-opf-policies.component.html @@ -1,25 +1,25 @@ - +
- {{'submission.sections.sherpa.publisher.policy.description' | translate}} + {{'submission.sections.jisc.publisher.policy.description' | translate}}
- - - + + +
@@ -34,7 +34,7 @@
@@ -51,7 +51,7 @@
@@ -59,14 +59,14 @@
- +
- + + [content]="!!opfData.message ? opfData.message : 'submission.sections.jisc.error.message'| translate"> diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.scss b/src/app/submission/sections/opf-policies/section-opf-policies.component.scss similarity index 100% rename from src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.scss rename to src/app/submission/sections/opf-policies/section-opf-policies.component.scss diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts b/src/app/submission/sections/opf-policies/section-opf-policies.component.spec.ts similarity index 83% rename from src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts rename to src/app/submission/sections/opf-policies/section-opf-policies.component.spec.ts index 5882a277e63..4cab44d0bde 100644 --- a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts +++ b/src/app/submission/sections/opf-policies/section-opf-policies.component.spec.ts @@ -21,7 +21,7 @@ import { APP_DATA_SERVICES_MAP } from '../../../../config/app-config.interface'; import { AppState } from '../../../app.reducer'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { AlertComponent } from '../../../shared/alert/alert.component'; -import { SherpaDataResponse } from '../../../shared/mocks/section-sherpa-policies.service.mock'; +import { OpfDataResponse } from '../../../shared/mocks/section-opf-policies.service.mock'; import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; @@ -30,11 +30,11 @@ import { SectionsService } from '../sections.service'; import { MetadataInformationComponent } from './metadata-information/metadata-information.component'; import { PublicationInformationComponent } from './publication-information/publication-information.component'; import { PublisherPolicyComponent } from './publisher-policy/publisher-policy.component'; -import { SubmissionSectionSherpaPoliciesComponent } from './section-sherpa-policies.component'; +import { SubmissionSectionJiscOpfPoliciesComponent } from './section-opf-policies.component'; -describe('SubmissionSectionSherpaPoliciesComponent', () => { - let component: SubmissionSectionSherpaPoliciesComponent; - let fixture: ComponentFixture; +describe('SubmissionSectionJiscOpfPoliciesComponent', () => { + let component: SubmissionSectionJiscOpfPoliciesComponent; + let fixture: ComponentFixture; let de: DebugElement; const sectionsServiceStub = new SectionsServiceStub(); @@ -48,20 +48,20 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { const storeStub = jasmine.createSpyObj('store', ['dispatch']); const sectionData = { - header: 'submit.progressbar.sherpaPolicies', - config: 'http://localhost:8080/server/api/config/submissionaccessoptions/SherpaPoliciesDefaultConfiguration', + header: 'submit.progressbar.opfPolicies', + config: 'http://localhost:8080/server/api/config/submissionaccessoptions/OpfPoliciesDefaultConfiguration', mandatory: true, - sectionType: 'sherpaPolicies', + sectionType: 'opfPolicies', collapsed: false, enabled: true, - data: SherpaDataResponse, + data: OpfDataResponse, errorsToShow: [], serverValidationErrors: [], isLoading: false, isValid: true, }; - describe('SubmissionSectionSherpaPoliciesComponent', () => { + describe('SubmissionSectionJiscOpfPoliciesComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ @@ -75,7 +75,7 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { }, }), NgbCollapseModule, - SubmissionSectionSherpaPoliciesComponent, + SubmissionSectionJiscOpfPoliciesComponent, ], providers: [ { provide: SectionsService, useValue: sectionsServiceStub }, @@ -87,7 +87,7 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], }) - .overrideComponent(SubmissionSectionSherpaPoliciesComponent, { + .overrideComponent(SubmissionSectionJiscOpfPoliciesComponent, { remove: { imports: [ MetadataInformationComponent, AlertComponent, @@ -99,10 +99,10 @@ describe('SubmissionSectionSherpaPoliciesComponent', () => { }); beforeEach(inject([Store], (store: Store) => { - fixture = TestBed.createComponent(SubmissionSectionSherpaPoliciesComponent); + fixture = TestBed.createComponent(SubmissionSectionJiscOpfPoliciesComponent); component = fixture.componentInstance; de = fixture.debugElement; - sectionsServiceStub.getSectionData.and.returnValue(observableOf(SherpaDataResponse)); + sectionsServiceStub.getSectionData.and.returnValue(observableOf(OpfDataResponse)); fixture.detectChanges(); })); diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts b/src/app/submission/sections/opf-policies/section-opf-policies.component.ts similarity index 82% rename from src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts rename to src/app/submission/sections/opf-policies/section-opf-policies.component.ts index dd614abef6c..66b69d9a5e9 100644 --- a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts +++ b/src/app/submission/sections/opf-policies/section-opf-policies.component.ts @@ -18,7 +18,7 @@ import { import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; -import { WorkspaceitemSectionSherpaPoliciesObject } from '../../../core/submission/models/workspaceitem-section-sherpa-policies.model'; +import { WorkspaceitemSectionJiscOpfPoliciesObject } from '../../../core/submission/models/workspaceitem-section-opf-policies.model'; import { AlertComponent } from '../../../shared/alert/alert.component'; import { AlertType } from '../../../shared/alert/alert-type'; import { @@ -35,12 +35,12 @@ import { PublicationInformationComponent } from './publication-information/publi import { PublisherPolicyComponent } from './publisher-policy/publisher-policy.component'; /** - * This component represents a section for the sherpa policy informations structure. + * This component represents a section for the Jisc Open Policy Finder policy information structure. */ @Component({ - selector: 'ds-section-sherpa-policies', - templateUrl: './section-sherpa-policies.component.html', - styleUrls: ['./section-sherpa-policies.component.scss'], + selector: 'ds-section-opf-policies', + templateUrl: './section-opf-policies.component.html', + styleUrls: ['./section-opf-policies.component.scss'], imports: [ MetadataInformationComponent, NgbCollapseModule, @@ -55,13 +55,13 @@ import { PublisherPolicyComponent } from './publisher-policy/publisher-policy.co ], standalone: true, }) -export class SubmissionSectionSherpaPoliciesComponent extends SectionModelComponent { +export class SubmissionSectionJiscOpfPoliciesComponent extends SectionModelComponent { /** * The accesses section data * @type {WorkspaceitemSectionAccessesObject} */ - public sherpaPoliciesData$: BehaviorSubject = new BehaviorSubject(null); + public jiscOpfPoliciesData$: BehaviorSubject = new BehaviorSubject(null); /** * The [[JsonPatchOperationPathCombiner]] object @@ -123,8 +123,8 @@ export class SubmissionSectionSherpaPoliciesComponent extends SectionModelCompon this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); this.subs.push( this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) - .subscribe((sherpaPolicies: WorkspaceitemSectionSherpaPoliciesObject) => { - this.sherpaPoliciesData$.next(sherpaPolicies); + .subscribe((opfPolicies: WorkspaceitemSectionJiscOpfPoliciesObject) => { + this.jiscOpfPoliciesData$.next(opfPolicies); }), ); } @@ -143,11 +143,11 @@ export class SubmissionSectionSherpaPoliciesComponent extends SectionModelCompon * Check if section has no data */ hasNoData(): boolean { - return isEmpty(this.sherpaPoliciesData$.value); + return isEmpty(this.jiscOpfPoliciesData$.value); } /** - * Refresh sherpa information + * Refresh Jisc Open Policy Finder information */ refresh() { this.operationsBuilder.remove(this.pathCombiner.getPath('retrievalTime')); diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts index 8e0df1cb239..862a10c0872 100644 --- a/src/app/submission/sections/sections-decorator.ts +++ b/src/app/submission/sections/sections-decorator.ts @@ -4,9 +4,9 @@ import { SubmissionSectionDuplicatesComponent } from './duplicates/section-dupli import { SubmissionSectionFormComponent } from './form/section-form.component'; import { SubmissionSectionIdentifiersComponent } from './identifiers/section-identifiers.component'; import { SubmissionSectionLicenseComponent } from './license/section-license.component'; +import { SubmissionSectionJiscOpfPoliciesComponent } from './opf-policies/section-opf-policies.component'; import { SubmissionSectionCoarNotifyComponent } from './section-coar-notify/section-coar-notify.component'; import { SectionsType } from './sections-type'; -import { SubmissionSectionSherpaPoliciesComponent } from './sherpa-policies/section-sherpa-policies.component'; import { SubmissionSectionUploadComponent } from './upload/section-upload.component'; const submissionSectionsMap = new Map(); @@ -14,7 +14,7 @@ const submissionSectionsMap = new Map(); submissionSectionsMap.set(SectionsType.AccessesCondition, SubmissionSectionAccessesComponent); submissionSectionsMap.set(SectionsType.License, SubmissionSectionLicenseComponent); submissionSectionsMap.set(SectionsType.CcLicense, SubmissionSectionCcLicensesComponent); -submissionSectionsMap.set(SectionsType.SherpaPolicies, SubmissionSectionSherpaPoliciesComponent); +submissionSectionsMap.set(SectionsType.OpfPolicies, SubmissionSectionJiscOpfPoliciesComponent); submissionSectionsMap.set(SectionsType.Upload, SubmissionSectionUploadComponent); submissionSectionsMap.set(SectionsType.SubmissionForm, SubmissionSectionFormComponent); submissionSectionsMap.set(SectionsType.Identifiers, SubmissionSectionIdentifiersComponent); diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 60b4cedfdc9..6c8662b6888 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -5,7 +5,7 @@ export enum SectionsType { License = 'license', CcLicense = 'cclicense', AccessesCondition = 'accessCondition', - SherpaPolicies = 'sherpaPolicy', + OpfPolicies = 'opfPolicy', Identifiers = 'identifiers', Collection = 'collection', CoarNotify = 'coarnotify', diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 034484581af..0bf00e5999c 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -541,7 +541,7 @@ export class SectionsService { * @param sectionType */ public getIsInformational(sectionType: SectionsType): boolean { - if (sectionType === SectionsType.SherpaPolicies) { + if (sectionType === SectionsType.OpfPolicies) { return true; } else { return false; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index cd045426769..2f1750e7a19 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -27,14 +27,14 @@