diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d8734dae15..123f9c93f12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,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 @@ -39,10 +40,11 @@ jobs: # Project name to use when running "docker compose" prior to e2e tests COMPOSE_PROJECT_NAME: 'ci' # Docker Registry to use for Docker compose scripts below. - # We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub. - # UMD Customization - #DOCKER_REGISTRY: ghcr.io - # End UMD Customization + # 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: @@ -53,11 +55,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v6 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} @@ -82,7 +84,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@v5 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -115,22 +117,25 @@ jobs: # 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 - 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' retention-days: 14 - # Login to our Docker registry, so that we can access private Docker images using "docker compose" below. - # UMD Customization - # - name: Login to ${{ env.DOCKER_REGISTRY }} - # uses: docker/login-action@v3 - # with: - # registry: ${{ env.DOCKER_REGISTRY }} - # username: ${{ github.repository_owner }} - # password: ${{ secrets.GITHUB_TOKEN }} - # End UMD Customization + # 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 @@ -144,7 +149,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.1.9 with: # Run tests in Chrome, headless mode (default) browser: chrome @@ -159,7 +164,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 }} @@ -168,7 +173,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 }} @@ -314,28 +319,32 @@ jobs: run: docker compose -f ./docker/docker-compose-ci.yml down # UMD Customization + # Commented out, because UMD does not have a an appropriate key for uploading the results to codecov.io. # # Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test # # job above. This is necessary because Codecov uploads seem to randomly fail at times. # # See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954 # codecov: # # Must run after 'tests' job above # needs: tests + # # 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@v6 + # # # 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. # # # # 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 # # Ensure codecov-action throws an error when it fails to upload diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index d96e786cc37..6d9ceff3703 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -35,19 +35,19 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: javascript # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 # Perform GitHub Code Scanning. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bae8c013005..81b9266eb4d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -57,4 +57,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@v6 + # 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..c8c421d98f4 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@v1.0.2 # 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..676ad45ba26 100644 --- a/.github/workflows/port_merged_pull_request.yml +++ b/.github/workflows/port_merged_pull_request.yml @@ -23,11 +23,11 @@ jobs: if: github.event.pull_request.merged steps: # Checkout code - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # 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 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..e2b6e8ba9c2 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.0.1 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 8ea58b96e40..e6429eaf689 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). @@ -87,6 +90,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 +433,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 5214a579ceb..9bf2b1b9ce7 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -1,13 +1,94 @@ import { testA11y } from 'cypress/support/utils'; -describe('Community List Page', () => { - - // UMD Customization - // The dspace backend in docker-compose-ci.yml uses stock dspace image, so the - // communitygroups endpoint used by /community-list page is not available. - // The default database restore SQL used by docker-compose-ci.yml does not include the - // CommuityGroup info, therefore this test will fail - it.skip('should pass accessibility tests', () => { +// UMD Customization +// The dspace backend in docker-compose-ci.yml uses stock dspace image, so the +// communitygroups endpoint used by /community-list page is not available. +// The default database restore SQL used by docker-compose-ci.yml does not include the +// CommuityGroup info, therefore this test will fail, therefore skipping these tests +describe.skip('Community List Page', () => { +// End UMD Customization + + 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'); // tag must be loaded @@ -19,5 +100,4 @@ describe('Community List Page', () => { // Analyze for accessibility issues testA11y('ds-community-list-page'); }); - // End UMD Customization }); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index ad5d8ea0930..4deab547a02 100644 --- a/cypress/e2e/item-edit.cy.ts +++ b/cypress/e2e/item-edit.cy.ts @@ -23,13 +23,27 @@ describe('Edit Item > Edit Metadata tab', () => { // tag must be loaded cy.get('ds-edit-item-page').should('be.visible'); + // 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'); + }); + // wait for all the ds-dso-edit-metadata-value components to be rendered cy.get('ds-dso-edit-metadata-value div[role="row"]').each(($row: HTMLDivElement) => { cy.wrap($row).find('div[role="cell"]').should('be.visible'); }); // Analyze for accessibility issues - testA11y('ds-edit-item-page'); + 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, + ); }); }); @@ -46,6 +60,11 @@ describe('Edit Item > Status tab', () => { // tag must be loaded cy.get('ds-item-status').should('be.visible'); + // 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'); + }); + // Analyze for accessibility issues testA11y('ds-item-status'); }); @@ -64,6 +83,10 @@ describe('Edit Item > Bitstreams tab', () => { // tag must be loaded cy.get('ds-item-bitstreams').should('be.visible'); + // 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'); + }); // Table of item bitstreams must also be loaded cy.get('div.item-bitstreams').should('be.visible'); @@ -93,6 +116,11 @@ describe('Edit Item > Curate tab', () => { // tag must be loaded cy.get('ds-item-curate').should('be.visible'); + // 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'); + }); + // Analyze for accessibility issues testA11y('ds-item-curate'); }); @@ -111,6 +139,11 @@ describe('Edit Item > Relationships tab', () => { // tag must be loaded cy.get('ds-item-relationships').should('be.visible'); + // 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'); + }); + // Analyze for accessibility issues testA11y('ds-item-relationships'); }); @@ -129,6 +162,11 @@ describe('Edit Item > Version History tab', () => { // tag must be loaded cy.get('ds-item-version-history').should('be.visible'); + // 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'); + }); + // Analyze for accessibility issues testA11y('ds-item-version-history'); }); @@ -147,6 +185,11 @@ describe('Edit Item > Access Control tab', () => { // tag must be loaded cy.get('ds-item-access-control').should('be.visible'); + // 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'); + }); + // Analyze for accessibility issues testA11y('ds-item-access-control'); }); @@ -165,6 +208,11 @@ describe('Edit Item > Collection Mapper tab', () => { // tag must be loaded cy.get('ds-item-collection-mapper').should('be.visible'); + // 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'); + }); + // Analyze entire page for accessibility issues testA11y('ds-item-collection-mapper'); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 159bb4f5e65..050bcf3e24c 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -131,4 +131,153 @@ 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('#dc_title').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('#granted').check(); + + //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 20460c945f2..4bfc1be7c98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "8.2.0", + "version": "8.4.0", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -54,6 +54,9 @@ "https": false }, "private": true, + "resolutions": { + "webpack": "5.106.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.27.6", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^16.0.0", @@ -78,38 +80,37 @@ "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "angulartics2": "^12.2.0", - "axios": "^1.10.0", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", "colors": "^1.4.0", - "compression": "^1.8.0", + "compression": "^1.8.1", "cookie-parser": "1.4.7", - "core-js": "^3.42.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-terminator": "^3.2.0", - "isbot": "^5.1.28", + "isbot": "^5.1.39", "js-cookie": "2.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "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.0", + "morgan": "^1.10.1", "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", @@ -117,6 +118,7 @@ "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", @@ -135,7 +137,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", @@ -148,28 +149,28 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.17", + "@types/lodash": "^4.17.24", "@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.10.3", + "axe-core": "^4.11.4", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "csstype": "^3.1.3", - "cypress": "^13.17.0", - "cypress-axe": "^1.6.0", + "csstype": "^3.2.3", + "cypress": "^14.5.4", + "cypress-axe": "^1.7.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", "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.31.0", - "eslint-plugin-import-newlines": "^1.3.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-newlines": "^1.4.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.20.1", + "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", @@ -184,7 +185,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ng-mocks": "^14.13.5", + "ng-mocks": "^14.15.2", "ngx-mask": "14.2.4", "nodemon": "^2.0.22", "postcss": "^8.5", @@ -193,16 +194,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.89.2", + "sass": "~1.99.0", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", "typescript": "~5.4.5", - "webpack": "5.99.9", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" + "webpack": "5.106.2", + "webpack-cli": "^5.1.4" } } diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 170266b6a28..6b3881b3b82 100644 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -38,11 +38,13 @@ function parseCliInput() { .usage('([-d ] [-s ]) || (-t (-i | -o ) [-s ])') .parse(process.argv); - if (!program.targetFile) { + const sourceFile = program.opts().sourceFile; + + if (!program.targetFile) { fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => { - if (!program.sourceFile.toString().endsWith(file)) { + if (!sourceFile.toString().endsWith(file)) { const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file); - console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.sourceFile); + console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + sourceFile); if (program.outputDir) { if (!fs.existsSync(program.outputDir)) { fs.mkdirSync(program.outputDir); @@ -67,7 +69,7 @@ function parseCliInput() { console.log(program.outputHelp()); process.exit(1); } - if (!checkIfFileExists(program.sourceFile)) { + if (!checkIfFileExists(sourceFile)) { console.error('Path of source file is not valid.'); console.log(program.outputHelp()); process.exit(1); @@ -101,7 +103,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { targetLines.push(line.trim()); })); progressBar.update(10); - const sourceFile = readFileIfExists(program.sourceFile); + const sourceFile = readFileIfExists(program.opts().sourceFile); sourceFile.toString().split("\n").forEach((function (line) { sourceLines.push(line.trim()); })); diff --git a/server.ts b/server.ts index 84c07229472..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, }); }); @@ -269,6 +273,12 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { ], }) .then((html) => { + // If headers were already sent, then do nothing else, it is probably a + // redirect response + if (res.headersSent) { + return; + } + if (hasValue(html)) { // Replace REST URL with UI URL if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { @@ -304,13 +314,23 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { }); } -/** - * Send back response to user to trigger direct client-side rendering (CSR) - * @param req current request - * @param res current response - */ +// Read file once at startup +const indexHtmlContent = readFileSync(indexHtml, 'utf8'); + function clientSideRender(req, res) { - res.sendFile(indexHtml); + const namespace = environment.ui.nameSpace || '/'; + let html = indexHtmlContent; + // 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.set('Cache-Control', 'no-cache, no-store').send(html); } @@ -321,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(); } @@ -561,8 +585,8 @@ function createHttpsServer(keys) { * Create an HTTP server with the configured port and host. */ function run() { - const port = environment.ui.port || 4000; - const host = environment.ui.host || '/'; + const port = environment.ui.port; + const host = environment.ui.host; // Start up the Node server const server = app(); @@ -648,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 f9507b9724c..9f4b9b3d9db 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 @@ -339,15 +339,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 79ac3105895..053fb619ce1 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 @@ -331,12 +331,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 e9b28aed787..ad21340f95b 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -33,6 +33,7 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; import { authBlockingGuard } from './core/auth/auth-blocking.guard'; import { authenticatedGuard } from './core/auth/authenticated.guard'; +import { notAuthenticatedGuard } from './core/auth/not-authenticated.guard'; import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; @@ -57,7 +58,7 @@ import { EMBARGO_LIST_PAGE_PATH } from './embargo-list/embargo-list-page-routing // End UMD Customization 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: '', @@ -79,7 +80,9 @@ export const APP_ROUTES: Route[] = [ data: { showBreadcrumbs: false, dsoPath: 'site', + // UMD Customization enableRSS: true, + // End UMD Customization }, providers: [provideSuggestionNotificationsState()], canActivate: [endUserAgreementCurrentUserGuard], @@ -110,26 +113,30 @@ export const APP_ROUTES: Route[] = [ path: REGISTER_PATH, loadChildren: () => import('./register-page/register-page-routes') .then((m) => m.ROUTES), - canActivate: [siteRegisterGuard], + canActivate: [notAuthenticatedGuard, siteRegisterGuard], }, { path: FORGOT_PASSWORD_PATH, loadChildren: () => import('./forgot-password/forgot-password-routes') .then((m) => m.ROUTES), - canActivate: [endUserAgreementCurrentUserGuard, forgotPasswordCheckGuard], + canActivate: [notAuthenticatedGuard, endUserAgreementCurrentUserGuard, forgotPasswordCheckGuard], }, { path: COMMUNITY_MODULE_PATH, loadChildren: () => import('./community-page/community-page-routes') .then((m) => m.ROUTES), + // UMD Customization data: { enableRSS: true }, + // End UMD Customization canActivate: [endUserAgreementCurrentUserGuard], }, { path: COLLECTION_MODULE_PATH, loadChildren: () => import('./collection-page/collection-page-routes') .then((m) => m.ROUTES), + // UMD Customization data: { showBreadcrumbs: false, enableRSS: true }, + // End UMD Customization canActivate: [endUserAgreementCurrentUserGuard], }, { @@ -160,7 +167,9 @@ export const APP_ROUTES: Route[] = [ path: 'mydspace', loadChildren: () => import('./my-dspace-page/my-dspace-page-routes') .then((m) => m.ROUTES), + // UMD Customization data: { enableRSS: true }, + // End UMD Customization providers: [provideSuggestionNotificationsState()], canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard], }, @@ -168,7 +177,9 @@ export const APP_ROUTES: Route[] = [ path: 'search', loadChildren: () => import('./search-page/search-page-routes') .then((m) => m.ROUTES), + // UMD Customization data: { enableRSS: true }, + // End UMD Customization canActivate: [endUserAgreementCurrentUserGuard], }, { @@ -181,7 +192,9 @@ export const APP_ROUTES: Route[] = [ path: ADMIN_MODULE_PATH, loadChildren: () => import('./admin/admin-routes') .then((m) => m.ROUTES), + // UMD Customization data: { enableRSS: true }, + // End UMD Customization canActivate: [siteAdministratorGuard, endUserAgreementCurrentUserGuard], }, { @@ -195,11 +208,13 @@ export const APP_ROUTES: Route[] = [ path: 'login', loadChildren: () => import('./login-page/login-page-routes') .then((m) => m.ROUTES), + canActivate: [notAuthenticatedGuard], }, { path: 'logout', loadChildren: () => import('./logout-page/logout-page-routes') .then((m) => m.ROUTES), + canActivate: [authenticatedGuard], }, { path: 'submit', @@ -226,7 +241,9 @@ export const APP_ROUTES: Route[] = [ providers: [provideSubmissionState()], loadChildren: () => import('./workflowitems-edit-page/workflowitems-edit-page-routes') .then((m) => m.ROUTES), + // UMD Customization data: { enableRSS: true }, + // End UMD Customization canActivate: [endUserAgreementCurrentUserGuard], }, { @@ -261,6 +278,7 @@ export const APP_ROUTES: Route[] = [ { path: FORBIDDEN_PATH, component: ThemedForbiddenComponent, + data: { title: '403.forbidden' }, }, { path: 'statistics', @@ -284,6 +302,7 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, + { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, // UMD Customization { path: EMBARGO_LIST_PAGE_PATH, @@ -301,7 +320,7 @@ export const APP_ROUTES: Route[] = [ canActivate: [endUserAgreementCurrentUserGuard], }, // End UMD Customization - { 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/collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts index c60e23e81e2..41fbf3d1e6a 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -161,7 +161,7 @@ export class CollectionItemMapperComponent implements OnInit { this.collectionName$ = this.collectionRD$.pipe( map((rd: RemoteData) => { - return this.dsoNameService.getName(rd.payload); + return this.dsoNameService.getName(rd.payload, true); }), ); this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; 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 d7293aa559f..6b8f8e83e51 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/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index f90a11f61a0..07a5e592b60 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -6,7 +6,6 @@ export enum AuthMethodType { Shibboleth = 'shibboleth', Ldap = 'ldap', Ip = 'ip', - X509 = 'x509', Oidc = 'oidc', Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 9890d0d5fb6..5d6ebb14718 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -22,10 +22,6 @@ export class AuthMethod { this.location = location; break; } - case 'x509': { - this.authMethodType = AuthMethodType.X509; - break; - } case 'password': { this.authMethodType = AuthMethodType.Password; break; diff --git a/src/app/core/auth/not-authenticated.guard.spec.ts b/src/app/core/auth/not-authenticated.guard.spec.ts new file mode 100644 index 00000000000..57102b48b66 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { + firstValueFrom, + of, +} from 'rxjs'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; +import { notAuthenticatedGuard } from './not-authenticated.guard'; + +describe('notAuthenticatedGuard', () => { + let authService: jasmine.SpyObj; + let hardRedirectService: jasmine.SpyObj; + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState = {} as RouterStateSnapshot; + + beforeEach(() => { + const authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']); + const redirectSpy = jasmine.createSpyObj('HardRedirectService', ['redirect']); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authSpy }, + { provide: HardRedirectService, useValue: redirectSpy }, + ], + }); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + hardRedirectService = TestBed.inject(HardRedirectService) as jasmine.SpyObj; + }); + + it('should block access and redirect if user is logged in', async () => { + authService.isAuthenticated.and.returnValue(of(true)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(false); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(PAGE_NOT_FOUND_PATH); + }); + + it('should allow access if user is not logged in', async () => { + authService.isAuthenticated.and.returnValue(of(false)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(true); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/auth/not-authenticated.guard.ts b/src/app/core/auth/not-authenticated.guard.ts new file mode 100644 index 00000000000..db21a5c7a98 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; + +export const notAuthenticatedGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const redirectService = inject(HardRedirectService); + + return authService.isAuthenticated().pipe( + map((isLoggedIn) => { + if (isLoggedIn) { + redirectService.redirect(PAGE_NOT_FOUND_PATH); + return false; + } + + return true; + }), + ); +}; diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 5f241b1a6cc..4bdd36a0c89 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -78,7 +78,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockPerson); - expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson); + expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -87,7 +87,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockOrgUnit); - expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit); + expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit, undefined); expect(result).toBe('Bingo!'); }); @@ -96,7 +96,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockEPerson); - expect((service as any).factories.EPerson).toHaveBeenCalledWith(mockEPerson); + expect((service as any).factories.EPerson).toHaveBeenCalledWith(mockEPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -105,7 +105,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockDSO); - expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO); + expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO, undefined); expect(result).toBe('Bingo!'); }); }); @@ -119,9 +119,9 @@ describe(`DSONameService`, () => { it(`should return 'person.familyName, person.givenName'`, () => { const result = (service as any).factories.Person(mockPerson); expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); - expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); @@ -133,9 +133,9 @@ describe(`DSONameService`, () => { it(`should return dc.title`, () => { const result = (service as any).factories.Person(mockPerson); expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); @@ -149,8 +149,8 @@ describe(`DSONameService`, () => { it(`should return 'eperson.firstname' and 'eperson.lastname'`, () => { const result = (service as any).factories.EPerson(mockEPerson); expect(result).toBe(mockEPersonName); - expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname'); - expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname'); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); }); }); @@ -162,8 +162,8 @@ describe(`DSONameService`, () => { it(`should return 'eperson.firstname'`, () => { const result = (service as any).factories.EPerson(mockEPersonFirst); expect(result).toBe(mockEPersonNameFirst); - expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname'); - expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname'); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); }); }); }); @@ -177,7 +177,7 @@ describe(`DSONameService`, () => { it(`should return 'organization.legalName'`, () => { const result = (service as any).factories.OrgUnit(mockOrgUnit); expect(result).toBe(mockOrgUnitName); - expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName'); + expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName', undefined, undefined); }); }); @@ -189,7 +189,7 @@ describe(`DSONameService`, () => { it(`should return 'dc.title'`, () => { const result = (service as any).factories.Default(mockDSO); expect(result).toBe(mockDSOName); - expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 988141209f4..b7daa8dd4e2 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -31,9 +31,9 @@ export class DSONameService { * With only two exceptions those solutions seem overkill for now. */ private readonly factories = { - EPerson: (dso: DSpaceObject): string => { - const firstName = dso.firstMetadataValue('eperson.firstname'); - const lastName = dso.firstMetadataValue('eperson.lastname'); + EPerson: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const firstName = dso.firstMetadataValue('eperson.firstname', undefined, escapeHTML); + const lastName = dso.firstMetadataValue('eperson.lastname', undefined, escapeHTML); if (isEmpty(firstName) && isEmpty(lastName)) { return this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(firstName) || isEmpty(lastName)) { @@ -42,23 +42,23 @@ export class DSONameService { return `${firstName} ${lastName}`; } }, - Person: (dso: DSpaceObject): string => { - const familyName = dso.firstMetadataValue('person.familyName'); - const givenName = dso.firstMetadataValue('person.givenName'); + Person: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const familyName = dso.firstMetadataValue('person.familyName', undefined, escapeHTML); + const givenName = dso.firstMetadataValue('person.givenName', undefined, escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return dso.firstMetadataValue('dc.title') || this.translateService.instant('dso.name.unnamed'); + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } else { return `${familyName}, ${givenName}`; } }, - OrgUnit: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('organization.legalName') || this.translateService.instant('dso.name.untitled'); + OrgUnit: (dso: DSpaceObject, escapeHTML?: boolean): string => { + return dso.firstMetadataValue('organization.legalName', undefined, escapeHTML); }, - Default: (dso: DSpaceObject): string => { + Default: (dso: DSpaceObject, escapeHTML?: boolean): string => { // If object doesn't have dc.title metadata use name property - return dso.firstMetadataValue('dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); }, }; @@ -66,8 +66,9 @@ export class DSONameService { * Get the name for the given {@link DSpaceObject} * * @param dso The {@link DSpaceObject} you want a name for + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute */ - getName(dso: DSpaceObject | undefined): string { + getName(dso: DSpaceObject | undefined, escapeHTML?: boolean): string { if (dso) { const types = dso.getRenderTypes(); const match = types @@ -76,10 +77,10 @@ export class DSONameService { let name; if (hasValue(match)) { - name = this.factories[match](dso); + name = this.factories[match](dso, escapeHTML); } if (isEmpty(name)) { - name = this.factories.Default(dso); + name = this.factories.Default(dso, escapeHTML); } return name; } else { @@ -92,27 +93,28 @@ export class DSONameService { * * @param object * @param dso + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} html embedded hit highlight. */ - getHitHighlights(object: any, dso: DSpaceObject): string { + getHitHighlights(object: any, dso: DSpaceObject, escapeHTML?: boolean): string { const types = dso.getRenderTypes(); const entityType = types .filter((type) => typeof type === 'string') .find((type: string) => (['Person', 'OrgUnit']).includes(type)) as string; if (entityType === 'Person') { - const familyName = this.firstMetadataValue(object, dso, 'person.familyName'); - const givenName = this.firstMetadataValue(object, dso, 'person.givenName'); + const familyName = this.firstMetadataValue(object, dso, 'person.familyName', escapeHTML); + const givenName = this.firstMetadataValue(object, dso, 'person.givenName', escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name; + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name; } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } return `${familyName}, ${givenName}`; } else if (entityType === 'OrgUnit') { - return this.firstMetadataValue(object, dso, 'organization.legalName') || this.translateService.instant('dso.name.untitled'); + return this.firstMetadataValue(object, dso, 'organization.legalName', escapeHTML); } - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); } /** @@ -121,11 +123,12 @@ export class DSONameService { * @param object * @param dso * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[]): string { - return Metadata.firstValue([object.hitHighlights, dso.metadata], keyOrKeys); + firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[], escapeHTML?: boolean): string { + return Metadata.firstValue(dso.metadata, keyOrKeys, object.hitHighlights, undefined, escapeHTML); } } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index b2d5476d21a..24c3aac8bbf 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -66,7 +66,7 @@ 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. @@ -80,8 +80,68 @@ export class CollectionDataService extends ComColDataService { * @return Observable>> * collection list */ - getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + 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. + * @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 + */ + 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 + */ + getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, searchHref: string = 'findSubmitAuthorized', ...linksToFollow: FollowLinkConfig[]): Observable>> { options = Object.assign({}, options, { searchParams: [new RequestParam('query', query)], }); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 6cfd29a9a27..e83d47496d8 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -15,9 +15,11 @@ import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getAllCompletedRemoteData } from '../shared/operators'; import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @@ -50,6 +52,92 @@ export class CommunityDataService extends ComColDataService { // End UMD Customization } + /** + * 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 + * + * @param query limit the returned community 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 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, searchHref: string = 'findAdminAuthorized', ...linksToFollow: FollowLinkConfig[]): Observable>> { + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)], + }); + + return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + // this method is overridden in order to make it public getEndpoint() { return this.halService.getEndpoint(this.linkPath); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index e1f789b5da7..d91cd1f0751 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); } /** @@ -348,6 +352,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 @@ -364,6 +388,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/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index e15fcd39077..ea27928f499 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -111,7 +111,7 @@ export class VersionHistoryDataService extends IdentifiableDataService (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`), + map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${encodeURIComponent(summary)}` : `${endpointUrl}`), find((href: string) => hasValue(href)), ).subscribe((href) => { const request = new PostRequest(requestId, href, itemHref, requestOptions); diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts index 4725820a6b4..8806f937c01 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -8,6 +8,7 @@ import { of } from 'rxjs'; import { RestRequestMethod } from '../data/rest-request-method'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { LocaleInterceptor } from './locale.interceptor'; import { LocaleService } from './locale.service'; @@ -17,11 +18,16 @@ 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'), + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -33,6 +39,7 @@ describe(`LocaleInterceptor`, () => { useClass: LocaleInterceptor, multi: true, }, + { provide: HALEndpointService, useValue: mockHalEndpointService }, { provide: LocaleService, useValue: mockLocaleService }, ], }); @@ -41,7 +48,9 @@ describe(`LocaleInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); localeService = TestBed.inject(LocaleService); - localeService.getCurrentLanguageCode.and.returnValue('en'); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); + localeService.getLanguageCodeList.and.returnValue(of(languageList)); + mockHalEndpointService.getRootHref.and.returnValue(rootHref); }); describe('', () => { @@ -70,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 6dfa19485d9..f005b333b12 100644 --- a/src/app/core/locale/locale.interceptor.ts +++ b/src/app/core/locale/locale.interceptor.ts @@ -9,14 +9,19 @@ import { Observable } from 'rxjs'; import { mergeMap, scan, + take, } from 'rxjs/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { LocaleService } from './locale.service'; @Injectable() export class LocaleInterceptor implements HttpInterceptor { - constructor(private localeService: LocaleService) { + constructor( + protected halEndpointService: HALEndpointService, + protected localeService: LocaleService, + ) { } /** @@ -26,8 +31,11 @@ export class LocaleInterceptor implements HttpInterceptor { */ intercept(req: HttpRequest, next: HttpHandler): Observable> { let newReq: HttpRequest; - return this.localeService.getLanguageCodeList() + const ignoreEPersonSettings: boolean = this.shouldIgnoreEPersonSettings(req.url); + + return this.localeService.getLanguageCodeList(ignoreEPersonSettings) .pipe( + take(1), scan((acc: any, value: any) => [...acc, value], []), mergeMap((languages) => { // Clone the request to add the new header. @@ -39,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/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts index d7f681056cb..2b2ef9eb205 100644 --- a/src/app/core/locale/locale.service.spec.ts +++ b/src/app/core/locale/locale.service.spec.ts @@ -7,9 +7,12 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { EPersonMock2 } from '../../shared/testing/eperson.mock'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; import { AuthService } from '../auth/auth.service'; import { CookieService } from '../services/cookie.service'; @@ -36,6 +39,7 @@ describe('LocaleService test suite', () => { authService = jasmine.createSpyObj('AuthService', { isAuthenticated: jasmine.createSpy('isAuthenticated'), isAuthenticationLoaded: jasmine.createSpy('isAuthenticationLoaded'), + getAuthenticatedUserFromStore: jasmine.createSpy('getAuthenticatedUserFromStore'), }); const langList = ['en', 'xx', 'de']; @@ -72,33 +76,80 @@ describe('LocaleService test suite', () => { }); describe('getCurrentLanguageCode', () => { + let testScheduler: TestScheduler; + beforeEach(() => { spyOn(translateService, 'getLangs').and.returnValue(langList); + testScheduler = new TestScheduler((actual, expected) => { + // use jasmine to test equality + expect(actual).toEqual(expected); + }); + authService.isAuthenticated.and.returnValue(of(false)); + authService.isAuthenticationLoaded.and.returnValue(of(false)); }); it('should return the language saved on cookie if it\'s a valid & active language', () => { spyOnGet.and.returnValue('de'); - expect(service.getCurrentLanguageCode()).toBe('de'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'de' }); + }); }); it('should return the default language if the cookie language is disabled', () => { spyOnGet.and.returnValue('disabled'); - expect(service.getCurrentLanguageCode()).toBe('en'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'en' }); + }); }); it('should return the default language if the cookie language does not exist', () => { spyOnGet.and.returnValue('does-not-exist'); - expect(service.getCurrentLanguageCode()).toBe('en'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'en' }); + }); }); it('should return language from browser setting', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('xx'); - expect(service.getCurrentLanguageCode()).toBe('xx'); + spyOn(service, 'getLanguageCodeList').and.returnValue(of(['xx', 'en'])); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'xx' }); + }); + }); + + it('should match language from browser setting case insensitive', () => { + spyOn(service, 'getLanguageCodeList').and.returnValue(of(['DE', 'en'])); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'DE' }); + }); + }); + }); + + describe('getLanguageCodeList', () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + spyOn(translateService, 'getLangs').and.returnValue(langList); + testScheduler = new TestScheduler((actual, expected) => { + // use jasmine to test equality + expect(actual).toEqual(expected); + }); + }); + + it('should return default language list without user preferred language when no logged in user', () => { + authService.isAuthenticated.and.returnValue(of(false)); + authService.isAuthenticationLoaded.and.returnValue(of(false)); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getLanguageCodeList()).toBe('(a|)', { a: ['en-US;q=1', 'en;q=0.9'] }); + }); }); - it('should return default language from config', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); - expect(service.getCurrentLanguageCode()).toBe('en'); + it('should return default language list with user preferred language when user is logged in', () => { + authService.isAuthenticated.and.returnValue(of(true)); + authService.isAuthenticationLoaded.and.returnValue(of(true)); + authService.getAuthenticatedUserFromStore.and.returnValue(of(EPersonMock2)); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getLanguageCodeList()).toBe('(a|)', { a: ['fr;q=0.5', 'en-US;q=1', 'en;q=0.9'] }); + }); }); }); @@ -130,14 +181,13 @@ describe('LocaleService test suite', () => { }); it('should set the current language', () => { - spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + spyOn(service, 'getCurrentLanguageCode').and.returnValue(of('es')); service.setCurrentLanguageCode(); expect(translateService.use).toHaveBeenCalledWith('es'); - expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es'); }); it('should set the current language on the html tag', () => { - spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + spyOn(service, 'getCurrentLanguageCode').and.returnValue(of('es')); service.setCurrentLanguageCode(); expect((service as any).document.documentElement.lang).toEqual('es'); }); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 0c54ce8412b..3c20f0e0cde 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -2,12 +2,14 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable, + OnDestroy, } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { combineLatest, Observable, of as observableOf, + Subscription, } from 'rxjs'; import { map, @@ -18,6 +20,7 @@ import { import { LangConfig } from '../../../config/lang-config.interface'; import { environment } from '../../../environments/environment'; import { + hasValue, isEmpty, isNotEmpty, } from '../../shared/empty.util'; @@ -44,13 +47,15 @@ export enum LANG_ORIGIN { * Service to provide localization handler */ @Injectable() -export class LocaleService { +export class LocaleService implements OnDestroy { /** * Eperson language metadata */ EPERSON_LANG_METADATA = 'eperson.language'; + subs: Subscription[] = []; + constructor( @Inject(NativeWindowService) protected _window: NativeWindowRef, protected cookie: CookieService, @@ -64,20 +69,25 @@ export class LocaleService { /** * Get the language currently used * - * @returns {string} The language code + * @returns {Observable} The language code */ - getCurrentLanguageCode(): string { + getCurrentLanguageCode(): Observable { // Attempt to get the language from a cookie - let lang = this.getLanguageCodeFromCookie(); + const lang = this.getLanguageCodeFromCookie(); if (isEmpty(lang) || environment.languages.find((langConfig: LangConfig) => langConfig.code === lang && langConfig.active) === undefined) { // Attempt to get the browser language from the user - if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { - lang = this.translate.getBrowserLang(); - } else { - lang = environment.defaultLanguage; - } + return this.getLanguageCodeList() + .pipe( + map(browserLangs => { + return browserLangs + .map(browserLang => browserLang.split(';')[0]) + .find(browserLang => + this.translate.getLangs().some(userLang => userLang.toLowerCase() === browserLang.toLowerCase()), + ) || environment.defaultLanguage; + }), + ); } - return lang; + return observableOf(lang); } /** @@ -85,18 +95,16 @@ export class LocaleService { * * @returns {Observable} */ - getLanguageCodeList(): Observable { + getLanguageCodeList(ignoreEPersonSettings = false): Observable { const obs$ = combineLatest([ this.authService.isAuthenticated(), this.authService.isAuthenticationLoaded(), ]); return obs$.pipe( - take(1), mergeMap(([isAuthenticated, isLoaded]) => { - // TODO to enabled again when https://github.com/DSpace/dspace-angular/issues/739 will be resolved - const epersonLang$: Observable = observableOf([]); - /* if (isAuthenticated && isLoaded) { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded && !ignoreEPersonSettings) { epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((eperson) => { @@ -109,21 +117,21 @@ export class LocaleService { !isEmpty(this.translate.currentLang))); } return languages; - }) + }), ); - }*/ + } return epersonLang$.pipe( map((epersonLang: string[]) => { const languages: string[] = []; + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } if (this.translate.currentLang) { languages.push(...this.setQuality( [this.translate.currentLang], LANG_ORIGIN.UI, false)); } - if (isNotEmpty(epersonLang)) { - languages.push(...epersonLang); - } if (navigator.languages) { languages.push(...this.setQuality( Object.assign([], navigator.languages), @@ -163,11 +171,16 @@ export class LocaleService { */ setCurrentLanguageCode(lang?: string): void { if (isEmpty(lang)) { - lang = this.getCurrentLanguageCode(); + this.subs.push(this.getCurrentLanguageCode().subscribe(curLang => { + lang = curLang; + this.translate.use(lang); + this.document.documentElement.lang = lang; + })); + } else { + this.saveLanguageCodeToCookie(lang); + this.translate.use(lang); + this.document.documentElement.lang = lang; } - this.translate.use(lang); - this.saveLanguageCodeToCookie(lang); - this.document.documentElement.lang = lang; } /** @@ -213,4 +226,10 @@ export class LocaleService { } + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/core/locale/server-locale.service.ts b/src/app/core/locale/server-locale.service.ts index 12358595d1a..da48d9a20a6 100644 --- a/src/app/core/locale/server-locale.service.ts +++ b/src/app/core/locale/server-locale.service.ts @@ -53,7 +53,7 @@ export class ServerLocaleService extends LocaleService { * * @returns {Observable} */ - getLanguageCodeList(): Observable { + getLanguageCodeList(ignoreEPersonSettings = false): Observable { const obs$ = combineLatest([ this.authService.isAuthenticated(), this.authService.isAuthenticationLoaded(), @@ -63,7 +63,7 @@ export class ServerLocaleService extends LocaleService { take(1), mergeMap(([isAuthenticated, isLoaded]) => { let epersonLang$: Observable = observableOf([]); - if (isAuthenticated && isLoaded) { + if (isAuthenticated && isLoaded && !ignoreEPersonSettings) { epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((eperson) => { diff --git a/src/app/core/metadata/head-tag.service.spec.ts b/src/app/core/metadata/head-tag.service.spec.ts index 2fbae88f120..96723fe6a96 100644 --- a/src/app/core/metadata/head-tag.service.spec.ts +++ b/src/app/core/metadata/head-tag.service.spec.ts @@ -24,6 +24,7 @@ import { MockBitstream1, MockBitstream2, MockBitstream3, + NonDiscoverableItemMock, } from '../../shared/mocks/item.mock'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { @@ -96,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), @@ -128,6 +129,37 @@ describe('HeadTagService', () => { ); }); + describe(`robots tag`, () => { + it(`should be set to noindex for non-discoverable items`, fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(NonDiscoverableItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'robots', + content: 'noindex', + }); + })); + it(`should not be set for discoverable items`, fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith({ + name: 'robots', + content: 'noindex', + }); + })); + }); + it('items page should set meta tags', fakeAsync(() => { (headTagService as any).processRouteChange({ data: { diff --git a/src/app/core/metadata/head-tag.service.ts b/src/app/core/metadata/head-tag.service.ts index d52efb8fa12..801a95c0fe5 100644 --- a/src/app/core/metadata/head-tag.service.ts +++ b/src/app/core/metadata/head-tag.service.ts @@ -173,6 +173,8 @@ export class HeadTagService { protected setDSOMetaTags(): void { + this.setNoIndexTag(); + this.setTitleTag(); this.setDescriptionTag(); @@ -210,6 +212,15 @@ export class HeadTagService { } + /** + * Add to the if non-discoverable item + */ + protected setNoIndexTag(): void { + if (this.currentObject.value instanceof Item && this.currentObject.value.isDiscoverable === false) { + this.addMetaTag('robots', 'noindex'); + } + } + /** * Add to the */ @@ -313,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); } @@ -396,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/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 7bc05b1d3aa..6ed3ea9105a 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -118,33 +118,36 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * Gets all matching metadata in this DSpaceObject. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue[]} the matching values or an empty array. */ - allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue[] { - return Metadata.all(this.metadata, keyOrKeys, valueFilter); + allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Like [[allMetadata]], but only returns string values. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string[] { - return Metadata.allValues(this.metadata, keyOrKeys, valueFilter); + allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): string[] { + return Metadata.allValues(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Gets the first matching MetadataValue object in this DSpaceObject, or `undefined`. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue} the first matching value, or `undefined`. */ - firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue { - return Metadata.first(this.metadata, keyOrKeys, valueFilter); + firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue { + return Metadata.first(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** @@ -152,26 +155,27 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { - return Metadata.firstValue(this.metadata, keyOrKeys, valueFilter); + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): string { + return Metadata.firstValue(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Checks for a matching metadata value in this DSpaceObject. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. * @returns {boolean} whether a match is found. */ hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { - return Metadata.has(this.metadata, keyOrKeys, valueFilter); + return Metadata.has(this.metadata, keyOrKeys, undefined, valueFilter); } /** * Find metadata on a specific field and order all of them using their "place" property. - * @param key + * @param keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. */ findMetadataSortedByPlace(keyOrKeys: string | string[]): MetadataValue[] { return this.allMetadata(keyOrKeys).sort((a: MetadataValue, b: MetadataValue) => { diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index 2ba96201b02..55fbbc78d5f 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -50,11 +50,11 @@ const multiViewModelList = [ { key: 'foo', ...bar, order: 0 }, ]; -const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, hitHighlights, expected, filter?) => { const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { - const result = fn(mapOrMaps, keys, filter); + const result = fn(mapOrMaps, keys, hitHighlights, filter); let shouldReturn; if (resultKind === 'boolean') { shouldReturn = expected; @@ -76,107 +76,107 @@ describe('Metadata', () => { describe('all method', () => { - const testAll = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => - testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, filter); + const testAll = (mapOrMaps, keyOrKeys, hitHighlights, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected, filter); describe('with emptyMap', () => { - testAll({}, 'foo', []); - testAll({}, '*', []); + testAll({}, 'foo', undefined, []); + testAll({}, '*', undefined, []); }); describe('with singleMap', () => { - testAll(singleMap, 'foo', []); - testAll(singleMap, '*', [dcTitle0]); - testAll(singleMap, '*', [], { value: 'baz' }); - testAll(singleMap, 'dc.title', [dcTitle0]); - testAll(singleMap, 'dc.*', [dcTitle0]); + testAll(singleMap, 'foo', undefined, []); + testAll(singleMap, '*', undefined, [dcTitle0]); + testAll(singleMap, '*', undefined, [], { value: 'baz' }); + testAll(singleMap, 'dc.title', undefined, [dcTitle0]); + testAll(singleMap, 'dc.*', undefined, [dcTitle0]); }); describe('with multiMap', () => { - testAll(multiMap, 'foo', [bar]); - testAll(multiMap, '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); - testAll(multiMap, 'dc.title', [dcTitle1, dcTitle2]); - testAll(multiMap, 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); - testAll(multiMap, ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + testAll(multiMap, 'foo', undefined, [bar]); + testAll(multiMap, '*', undefined, [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(multiMap, 'dc.title', undefined, [dcTitle1, dcTitle2]); + testAll(multiMap, 'dc.*', undefined, [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(multiMap, ['dc.title', 'dc.*'], undefined, [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); describe('with [ singleMap, multiMap ]', () => { - testAll([singleMap, multiMap], 'foo', [bar]); - testAll([singleMap, multiMap], '*', [dcTitle0]); - testAll([singleMap, multiMap], 'dc.title', [dcTitle0]); - testAll([singleMap, multiMap], 'dc.*', [dcTitle0]); + testAll(multiMap, 'foo', singleMap, [bar]); + testAll(multiMap, '*', singleMap, [dcTitle0]); + testAll(multiMap, 'dc.title', singleMap, [dcTitle0]); + testAll(multiMap, 'dc.*', singleMap, [dcTitle0]); }); describe('with [ multiMap, singleMap ]', () => { - testAll([multiMap, singleMap], 'foo', [bar]); - testAll([multiMap, singleMap], '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); - testAll([multiMap, singleMap], 'dc.title', [dcTitle1, dcTitle2]); - testAll([multiMap, singleMap], 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); - testAll([multiMap, singleMap], ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + testAll(singleMap, 'foo', multiMap, [bar]); + testAll(singleMap, '*', multiMap, [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(singleMap, 'dc.title', multiMap, [dcTitle1, dcTitle2]); + testAll(singleMap, 'dc.*', multiMap, [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(singleMap, ['dc.title', 'dc.*'], multiMap, [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); describe('with regexTestMap', () => { - testAll(regexTestMap, 'foo.bar.*', []); + testAll(regexTestMap, 'foo.bar.*', undefined, []); }); }); describe('allValues method', () => { - const testAllValues = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, expected); + const testAllValues = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testAllValues({}, '*', []); + testAllValues({}, '*', undefined, []); }); describe('with singleMap', () => { - testAllValues([singleMap, multiMap], '*', [dcTitle0.value]); + testAllValues(multiMap, '*', singleMap, [dcTitle0.value]); }); describe('with [ multiMap, singleMap ]', () => { - testAllValues([multiMap, singleMap], '*', [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); + testAllValues(singleMap, '*', multiMap, [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); }); }); describe('first method', () => { - const testFirst = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, expected); + const testFirst = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testFirst({}, '*', undefined); + testFirst({}, '*', undefined, undefined); }); describe('with singleMap', () => { - testFirst(singleMap, '*', dcTitle0); + testFirst(singleMap, '*', undefined, dcTitle0); }); describe('with [ multiMap, singleMap ]', () => { - testFirst([multiMap, singleMap], '*', dcDescription); + testFirst(singleMap, '*', multiMap, dcDescription); }); }); describe('firstValue method', () => { - const testFirstValue = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, expected); + const testFirstValue = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testFirstValue({}, '*', undefined); + testFirstValue({}, '*', undefined, undefined); }); describe('with singleMap', () => { - testFirstValue(singleMap, '*', dcTitle0.value); + testFirstValue(singleMap, '*', undefined, dcTitle0.value); }); describe('with [ multiMap, singleMap ]', () => { - testFirstValue([multiMap, singleMap], '*', dcDescription.value); + testFirstValue(singleMap, '*', multiMap, dcDescription.value); }); }); describe('has method', () => { - const testHas = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => - testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, expected, filter); + const testHas = (mapOrMaps, keyOrKeys, hitHighlights, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, hitHighlights, expected, filter); describe('with emptyMap', () => { - testHas({}, '*', false); + testHas({}, '*', undefined, false); }); describe('with singleMap', () => { - testHas(singleMap, '*', true); - testHas(singleMap, '*', false, { value: 'baz' }); + testHas(singleMap, '*', undefined, true); + testHas(singleMap, '*', undefined, false, { value: 'baz' }); }); describe('with [ multiMap, singleMap ]', () => { - testHas([multiMap, singleMap], '*', true); + testHas(singleMap, '*', multiMap, true); }); }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index f0290eac398..915ead48dd1 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,8 +1,8 @@ +import escape from 'lodash/escape'; import groupBy from 'lodash/groupBy'; import sortBy from 'lodash/sortBy'; import { - isEmpty, isNotEmpty, isNotUndefined, isUndefined, @@ -32,94 +32,120 @@ export class Metadata { /** * Gets all matching metadata in the map(s). * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be - * checked in order, and only values from the first with at least one match will be returned. + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue[]} the matching values or an empty array. */ - public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): MetadataValue[] { - const mdMaps: MetadataMapInterface[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; + public static all(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue[] { const matches: MetadataValue[] = []; - for (const mdMap of mdMaps) { - for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const candidates = mdMap[mdKey]; - if (candidates) { - for (const candidate of candidates) { + if (isNotEmpty(hitHighlights)) { + for (const mdKey of Metadata.resolveKeys(hitHighlights, keyOrKeys)) { + if (hitHighlights[mdKey]) { + for (const candidate of hitHighlights[mdKey]) { if (Metadata.valueMatches(candidate as MetadataValue, filter)) { matches.push(candidate as MetadataValue); } } } } - if (!isEmpty(matches)) { + if (isNotEmpty(matches)) { return matches; } } + for (const mdKey of Metadata.resolveKeys(metadata, keyOrKeys)) { + if (metadata[mdKey]) { + for (const candidate of metadata[mdKey]) { + if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + if (escapeHTML) { + matches.push(Object.assign(new MetadataValue(), candidate, { + value: escape(candidate.value), + })); + } else { + matches.push(candidate as MetadataValue); + } + } + } + } + } return matches; } /** * Like [[Metadata.all]], but only returns string values. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be - * checked in order, and only values from the first with at least one match will be returned. + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - public static allValues(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): string[] { - return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); + public static allValues(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): string[] { + return Metadata.all(metadata, keyOrKeys, hitHighlights, filter, escapeHTML).map((mdValue) => mdValue.value); } /** * Gets the first matching MetadataValue object in the map(s), or `undefined`. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue} the first matching value, or `undefined`. */ - public static first(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): MetadataValue { - const mdMaps: MetadataMapInterface[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; - for (const mdMap of mdMaps) { - for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const values: MetadataValue[] = mdMap[key] as MetadataValue[]; + public static first(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue { + if (isNotEmpty(hitHighlights)) { + for (const key of Metadata.resolveKeys(hitHighlights, keyOrKeys)) { + const values: MetadataValue[] = hitHighlights[key] as MetadataValue[]; if (values) { return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); } } } + for (const key of Metadata.resolveKeys(metadata, keyOrKeys)) { + const values: MetadataValue[] = metadata[key] as MetadataValue[]; + if (values) { + const result: MetadataValue = values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); + if (escapeHTML) { + return Object.assign(new MetadataValue(), result, { + value: escape(result.value), + }); + } + return result; + } + } } /** * Like [[Metadata.first]], but only returns a string value, or `undefined`. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - public static firstValue(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): string { - const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); + public static firstValue(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): string { + const value = Metadata.first(metadata, keyOrKeys, hitHighlights, filter, escapeHTML); return isUndefined(value) ? undefined : value.value; } /** * Checks for a matching metadata value in the given map(s). * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {boolean} whether a match is found. */ - public static has(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): boolean { - return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); + public static has(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter): boolean { + return isNotUndefined(Metadata.first(metadata, keyOrKeys, hitHighlights, filter)); } /** diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index b9e0207cc30..9b675d6b93b 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -183,7 +183,7 @@ export class SearchConfigurationService implements OnDestroy { */ getCurrentQuery(defaultQuery: string) { return this.routeService.getQueryParameterValue('query').pipe(map((query) => { - return query || defaultQuery; + return query !== null ? query : defaultQuery; // Allow querying when the value is empty })); } 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/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html index ec4dbd43236..c661c55bceb 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html @@ -1,12 +1,12 @@ - + + [ngbTooltip]="mdRepresentation.hasMetadata(['dc.description']) ? descTemplate : null"> diff --git a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html index cbc68ef7cf9..32d410412fd 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html @@ -2,7 +2,7 @@ - + ; diff --git a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html index acc9173bf7d..4c1f9266d6b 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html @@ -1,12 +1,12 @@ - + - \ No newline at end of file + [innerHTML]="dsoNameService.getName(mdRepresentation, true)" + [ngbTooltip]="dsoNameService.getName(mdRepresentation, true).length > 0 ? descTemplate : null"> + 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/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index bc962e27ffe..cc3a3b6789a 100644 --- a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -148,7 +148,7 @@ export class ItemCollectionMapperComponent implements OnInit { this.itemName$ = this.itemRD$.pipe( filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { - return this.dsoNameService.getName(rd.payload); + return this.dsoNameService.getName(rd.payload, true); }), ); this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; 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 8c534e66309..f2b87a9f5bc 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) { +
+

{{ label | translate }}

+ +
+} 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.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts index 7c6994f0837..1077dfdfcb1 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.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 { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { CreateCollectionParentSelectorComponent } from './create-collection-parent-selector.component'; describe('CreateCollectionParentSelectorComponent', () => { @@ -64,7 +64,7 @@ describe('CreateCollectionParentSelectorComponent', () => { schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(CreateCollectionParentSelectorComponent, { - remove: { imports: [DSOSelectorComponent] }, + remove: { imports: [AuthorizedCommunitySelectorComponent] }, }) .compileComponents(); 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 05efb987a33..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 { @@ -22,7 +23,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -34,14 +35,15 @@ import { @Component({ selector: 'ds-base-create-collection-parent-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './create-collection-parent-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule], }) export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { 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 a8ec02239d3..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 @@ -15,6 +15,9 @@ {{'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.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts index 04922d4deb4..b457419d913 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts @@ -21,7 +21,7 @@ import { Community } from '../../../../core/shared/community.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 { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { CreateCommunityParentSelectorComponent } from './create-community-parent-selector.component'; describe('CreateCommunityParentSelectorComponent', () => { @@ -69,7 +69,7 @@ describe('CreateCommunityParentSelectorComponent', () => { schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(CreateCommunityParentSelectorComponent, { - remove: { imports: [DSOSelectorComponent] }, + remove: { imports: [AuthorizedCommunitySelectorComponent] }, }) .compileComponents(); 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 dc49fcaa8af..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 { @@ -29,7 +30,7 @@ import { FeatureID } from '../../../../core/data/feature-authorization/feature-i import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { hasValue } from '../../../empty.util'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -49,7 +50,7 @@ import { standalone: true, imports: [ AsyncPipe, - DSOSelectorComponent, + AuthorizedCommunitySelectorComponent, NgIf, TranslateModule, ], @@ -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 new file mode 100644 index 00000000000..9a17b1aa9b2 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -0,0 +1,13 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts index 43b12889254..08280fb85c7 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Collection } from '../../../../core/shared/collection.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 { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { EditCollectionSelectorComponent } from './edit-collection-selector.component'; describe('EditCollectionSelectorComponent', () => { @@ -64,7 +64,7 @@ describe('EditCollectionSelectorComponent', () => { }) .overrideComponent(EditCollectionSelectorComponent, { remove: { - imports: [DSOSelectorComponent], + imports: [AuthorizedCollectionSelectorComponent], }, }) .compileComponents(); 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 611a4f13dec..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'; @@ -18,7 +19,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -31,14 +32,16 @@ import { @Component({ selector: 'ds-base-edit-collection-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-collection-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCollectionSelectorComponent, TranslateModule], }) export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { 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 new file mode 100644 index 00000000000..1ef1806d62b --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -0,0 +1,13 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts index cd5c0d1831a..750df1c268f 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.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 { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { EditCommunitySelectorComponent } from './edit-community-selector.component'; describe('EditCommunitySelectorComponent', () => { @@ -64,7 +64,7 @@ describe('EditCommunitySelectorComponent', () => { }) .overrideComponent(EditCommunitySelectorComponent, { remove: { - imports: [DSOSelectorComponent], + imports: [AuthorizedCommunitySelectorComponent], }, }) .compileComponents(); 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 3f7ede0de0d..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'; @@ -18,7 +19,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -31,15 +32,17 @@ import { @Component({ selector: 'ds-base-edit-community-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-community-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule], }) export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { 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 7224f1843dd..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,8 +1,8 @@ -
-
@@ -78,7 +82,7 @@ -
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss index 4e58759f4e7..ca8924da1ab 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss @@ -14,3 +14,13 @@ -moz-appearance: none; appearance: none; } + +.invalid-feedback { + margin-top: 0; +} + +.col-form-label { + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0.5rem; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 7600e25d894..192b05a28c7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -52,10 +52,14 @@ import { DynamicNGBootstrapTextAreaComponent, DynamicNGBootstrapTimePickerComponent, } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { Actions } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { NgxMaskModule } from 'ngx-mask'; -import { of as observableOf } from 'rxjs'; +import { + of as observableOf, + ReplaySubject, +} from 'rxjs'; import { APP_CONFIG, @@ -67,7 +71,16 @@ import { Item } from '../../../../core/shared/item.model'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { + SaveForLaterSubmissionFormErrorAction, + SaveSubmissionFormErrorAction, + SaveSubmissionFormSuccessAction, + SaveSubmissionSectionFormErrorAction, + SaveSubmissionSectionFormSuccessAction, +} from '../../../../submission/objects/submission-objects.actions'; import { SubmissionService } from '../../../../submission/submission.service'; +import { LiveRegionService } from '../../../live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../../live-region/live-region.service.stub'; import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { FormBuilderService } from '../form-builder.service'; @@ -207,7 +220,10 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { let testElement: DebugElement; const testItem: Item = new Item(); const testWSI: WorkspaceItem = new WorkspaceItem(); + const actions$: ReplaySubject = new ReplaySubject(1); testWSI.item = observableOf(createSuccessfulRemoteDataObject(testItem)); + const renderer = jasmine.createSpyObj('Renderer2', ['setAttribute']); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -240,6 +256,8 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, + { provide: Actions, useValue: actions$ }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents().then(() => { @@ -270,6 +288,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { }); fixture.detectChanges(); + renderer.setAttribute.calls.reset(); testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); })); @@ -382,4 +401,79 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { expect(testFn(formModel[25])).toEqual(DsDynamicFormGroupComponent); }); + describe('store action subscriptions', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should call announceErrorMessages on SAVE_SUBMISSION_FORM_SUCCESS', () => { + spyOn(component, 'announceErrorMessages'); + actions$.next(new SaveSubmissionFormSuccessAction('1234', [] as any)); + expect(component.announceErrorMessages).toHaveBeenCalled(); + }); + + it('should call announceErrorMessages on SAVE_SUBMISSION_SECTION_FORM_SUCCESS', () => { + spyOn(component, 'announceErrorMessages'); + actions$.next(new SaveSubmissionSectionFormSuccessAction('1234', [] as any)); + expect(component.announceErrorMessages).toHaveBeenCalled(); + }); + + it('should call announceErrorMessages on SAVE_SUBMISSION_FORM_ERROR', () => { + spyOn(component, 'announceErrorMessages'); + actions$.next(new SaveSubmissionFormErrorAction('1234')); + expect(component.announceErrorMessages).toHaveBeenCalled(); + }); + + it('should call announceErrorMessages on SAVE_FOR_LATER_SUBMISSION_FORM_ERROR', () => { + spyOn(component, 'announceErrorMessages'); + actions$.next(new SaveForLaterSubmissionFormErrorAction('1234')); + expect(component.announceErrorMessages).toHaveBeenCalled(); + }); + + it('should call announceErrorMessages on SAVE_SUBMISSION_SECTION_FORM_ERROR', () => { + spyOn(component, 'announceErrorMessages'); + actions$.next(new SaveSubmissionSectionFormErrorAction('1234')); + expect(component.announceErrorMessages).toHaveBeenCalled(); + }); + }); + + it('should not show a label if is a checkbox or a date field', () => { + const checkboxLabel = fixture.debugElement.query(By.css('#label_' + formModel[0].id)); + const dsDatePickerLabel = fixture.debugElement.query(By.css('#label_' + formModel[22].id)); + + expect(checkboxLabel).toBeNull(); + expect(dsDatePickerLabel).toBeNull(); + }); + + it('should not call handleAriaLabelForLibraryComponents if is SSR', () => { + (component as any).platformId = 'server'; + (component as any).componentRef = { + instance: new DynamicNGBootstrapInputComponent(null, null), + location: { nativeElement: document.createElement('div') }, + } as any; + fixture.detectChanges(); + + (component as any).handleAriaLabelForLibraryComponents(); + + expect(renderer.setAttribute).not.toHaveBeenCalled(); + }); + + it('should set aria-label when valid input and additional property ariaLabel exist and is on browser', () => { + (component as any).platformId = 'browser'; + const inputEl = document.createElement('input'); + const hostEl = { + querySelector: jasmine.createSpy('querySelector').and.returnValue(inputEl), + }; + + (component as any).componentRef = { + instance: new DynamicNGBootstrapInputComponent(null, null), + location: { nativeElement: hostEl }, + } as any; + (component as any).renderer = renderer; + component.model = { additional: { ariaLabel: 'Accessible Label' } } as any; + fixture.detectChanges(); + (component as any).handleAriaLabelForLibraryComponents(); + expect(renderer.setAttribute).toHaveBeenCalledWith(inputEl, 'aria-label', 'Accessible Label'); + }); + }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index ab50bd19dfc..6aa4e3d7bba 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -1,5 +1,6 @@ import { AsyncPipe, + isPlatformBrowser, NgClass, NgForOf, NgIf, @@ -15,18 +16,22 @@ import { DoCheck, EventEmitter, Inject, + inject, Input, OnChanges, OnDestroy, OnInit, Output, + PLATFORM_ID, QueryList, + Renderer2, SimpleChanges, Type, ViewChild, ViewContainerRef, } from '@angular/core'; import { + AbstractControl, FormsModule, ReactiveFormsModule, UntypedFormArray, @@ -40,10 +45,12 @@ import { import { DYNAMIC_FORM_CONTROL_MAP_FN, DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX, + DynamicFormArrayComponent, DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormComponentService, DynamicFormControl, + DynamicFormControlComponent, DynamicFormControlContainerComponent, DynamicFormControlEvent, DynamicFormControlEventType, @@ -55,6 +62,10 @@ import { DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; import { DynamicFormControlMapFn } from '@ng-dynamic-forms/core/lib/service/dynamic-form-component.service'; +import { + Actions, + ofType, +} from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateModule, @@ -100,14 +111,15 @@ import { import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; import { paginatedRelationsToItems } from '../../../../item-page/simple/item-types/shared/item-relationships-utils'; +import { SubmissionObjectActionTypes } from '../../../../submission/objects/submission-objects.actions'; import { SubmissionService } from '../../../../submission/submission.service'; -import { BtnDisabledDirective } from '../../../btn-disabled.directive'; import { hasNoValue, hasValue, isNotEmpty, isNotUndefined, } from '../../../empty.util'; +import { LiveRegionService } from '../../../live-region/live-region.service'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer'; import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; @@ -124,6 +136,7 @@ import { } from './existing-metadata-list-element/existing-metadata-list-element.component'; import { ExistingRelationListElementComponent } from './existing-relation-list-element/existing-relation-list-element.component'; import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/date-picker.model'; import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; @Component({ @@ -143,7 +156,6 @@ import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/d NgbTooltipModule, NgTemplateOutlet, ExistingRelationListElementComponent, - BtnDisabledDirective, ], standalone: true, }) @@ -178,6 +190,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo */ private subs: Subscription[] = []; + private liveRegionErrorMessagesShownAlready = false; + /* eslint-disable @angular-eslint/no-output-rename */ @Output('dfBlur') blur: EventEmitter = new EventEmitter(); @Output('dfChange') change: EventEmitter = new EventEmitter(); @@ -197,6 +211,8 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.dynamicFormControlFn(this.model); } + private readonly liveRegionService = inject(LiveRegionService); + constructor( protected componentFactoryResolver: ComponentFactoryResolver, protected dynamicFormComponentService: DynamicFormComponentService, @@ -216,6 +232,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo protected metadataService: MetadataService, @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(DYNAMIC_FORM_CONTROL_MAP_FN) protected dynamicFormControlFn: DynamicFormControlMapFn, + private actions$: Actions, + protected renderer: Renderer2, + @Inject(PLATFORM_ID) protected platformId: string, ) { super(ref, componentFactoryResolver, layoutService, validationService, dynamicFormComponentService, relationService); this.fetchThumbnail = this.appConfig.browseBy.showThumbnails; @@ -228,6 +247,18 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.isRelationship = hasValue(this.model.relationship); const isWrapperAroundRelationshipList = hasValue(this.model.relationshipConfig); + // Subscribe to specified submission actions to announce error messages + const errorAnnounceActionsSub = this.actions$.pipe( + ofType( + SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_SUCCESS, + SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_SUCCESS, + SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM_ERROR, + SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM_ERROR, + SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM_ERROR, + ), + ).subscribe(() => this.announceErrorMessages()); + this.subs.push(errorAnnounceActionsSub); + if (this.isRelationship || isWrapperAroundRelationshipList) { const config = this.model.relationshipConfig || this.model.relationship; const relationshipOptions = Object.assign(new RelationshipOptions(), config); @@ -305,6 +336,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX || this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; } + + get isDateField(): boolean { + return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; + } + ngOnChanges(changes: SimpleChanges) { if (changes && !this.isRelationship && hasValue(this.group.get(this.model.id))) { super.ngOnChanges(changes); @@ -326,6 +362,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo ngAfterViewInit() { this.showErrorMessagesPreviousStage = this.showErrorMessages; + this.handleAriaLabelForLibraryComponents(); } protected createFormControlComponent(): void { @@ -352,6 +389,36 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo if (this.showErrorMessages) { this.destroyFormControlComponent(); this.createFormControlComponent(); + this.announceErrorMessages(); + } + } + + /** + * Announce error messages to the user + */ + announceErrorMessages() { + if (!this.liveRegionErrorMessagesShownAlready) { + this.liveRegionErrorMessagesShownAlready = true; + const numberOfInvalidInputs = this.getNumberOfInvalidInputs() ?? 1; + const timeoutMs = numberOfInvalidInputs * 3500; + this.errorMessages.forEach((errorMsg) => { + // set timer based on the number of the invalid inputs + this.liveRegionService.setMessageTimeOutMs(timeoutMs); + const message = this.translateService.instant(errorMsg); + this.liveRegionService.addMessage(message); + }); + setTimeout(() => { + this.liveRegionErrorMessagesShownAlready = false; + }, timeoutMs); + } + } + + /** + * Get the number of invalid inputs in the formGroup + */ + private getNumberOfInvalidInputs(): number { + if (this.formGroup && this.formGroup.controls) { + return Object.values(this.formGroup.controls).filter((control: AbstractControl) => control.invalid).length; } } @@ -440,6 +507,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo * Unsubscribe from all subscriptions */ ngOnDestroy(): void { + super.ngOnDestroy(); this.subs .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); @@ -466,4 +534,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.subs.push(collection$.subscribe((collection) => this.collection = collection)); } + + private handleAriaLabelForLibraryComponents(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if ((this.componentRef.instance instanceof DynamicFormControlComponent) && + !(this.componentRef.instance instanceof DynamicFormArrayComponent) && + this.componentRef.location.nativeElement) { + const inputEl: HTMLElement | null = + this.componentRef.location.nativeElement.querySelector('input,textarea,select,[role="textbox"]'); + + + if (inputEl && this.model?.additional?.ariaLabel) { + this.renderer.setAttribute(inputEl, 'aria-label', this.model.additional.ariaLabel); + } + } + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts index a54e379cacb..883da2295ab 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts @@ -9,12 +9,9 @@ import { } from '@angular/forms'; import { DISABLED_MATCHER_PROVIDER, - DynamicFormControlRelation, DynamicFormRelationService, HIDDEN_MATCHER, HIDDEN_MATCHER_PROVIDER, - MATCH_VISIBLE, - OR_OPERATOR, REQUIRED_MATCHER_PROVIDER, } from '@ng-dynamic-forms/core'; @@ -26,6 +23,7 @@ import { import { FormBuilderService } from '../form-builder.service'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; +import { getTypeBindRelations } from './type-bind.utils'; describe('DSDynamicTypeBindRelationService test suite', () => { let service: DsDynamicTypeBindRelationService; @@ -85,7 +83,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { }); it('Should get 1 related form models for mock relation model data', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const relatedModels = service.getRelatedFormModel(testModel); expect(relatedModels).toHaveSize(1); }); @@ -94,7 +92,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { describe('Test matchesCondition method', () => { it('Should receive one subscription to dc.type type binding"', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); let subscriptions = service.subscribeRelations(testModel, dcTypeControl); @@ -103,7 +101,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { it('Expect hasMatch to be true (ie. this should be hidden)', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); testModel.typeBindRelations[0].when[0].value = 'anotherType'; @@ -118,7 +116,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { it('Expect hasMatch to be false (ie. this should NOT be hidden)', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); testModel.typeBindRelations[0].when[0].value = 'boundType'; @@ -134,18 +132,3 @@ describe('DSDynamicTypeBindRelationService test suite', () => { }); }); - -function getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: 'dc.type', - value: value, - }); - }); - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; -} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts index 03ca4b26cfc..4f8cff747e6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -13,7 +13,6 @@ import { DynamicFormControlModel, DynamicFormControlRelation, DynamicFormRelationService, - MATCH_VISIBLE, OR_OPERATOR, } from '@ng-dynamic-forms/core'; import { Subscription } from 'rxjs'; @@ -216,23 +215,4 @@ export class DsDynamicTypeBindRelationService { return subscriptions; } - /** - * Helper function to construct a typeBindRelations array - * @param configuredTypeBindValues - */ - public getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: 'dc.type', - value: value, - }); - }); - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; - } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html index 944e4650abd..a71f6fa5a26 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html @@ -1,5 +1,5 @@
- + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index 3ad70632564..6c1edcbdee1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -25,7 +25,7 @@ (keydown.escape)="cancelKeyboardDragAndDrop(sortableElement, idx, length)" (keydown.arrowUp)="handleArrowPress($event, dropList, length, idx, 'up')" (keydown.arrowDown)="handleArrowPress($event, dropList, length, idx, 'down')"> - +
.col { + padding-left: 5px; + padding-right: 5px; + } + .cdk-drag-handle { + width: calc(2 * var(--bs-spacer)); + } + .drag-icon { width: calc(2 * var(--bs-spacer)); color: var(--bs-gray-600); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts index 707ea485236..3375fd14b40 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.spec.ts @@ -14,13 +14,17 @@ import { DynamicFormValidationService, DynamicInputModel, } from '@ng-dynamic-forms/core'; +import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService, } from '@ngx-translate/core'; import { NgxMaskModule } from 'ngx-mask'; -import { of } from 'rxjs'; +import { + Observable, + of, +} from 'rxjs'; import { APP_CONFIG, @@ -63,6 +67,7 @@ describe('DsDynamicFormArrayComponent', () => { { provide: TranslateService, useValue: translateServiceStub }, { provide: HttpClient, useValue: {} }, { provide: SubmissionService, useValue: {} }, + provideMockActions(() => new Observable()), { provide: APP_CONFIG, useValue: environment }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html index 3dab2d214a7..c3da59c8beb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -1,7 +1,7 @@
-
+
- {{model.placeholder}} * + {{model.label}} * - - { FormComponent, FormService, provideMockStore({ initialState }), + provideMockActions(() => new Observable()), { provide: VocabularyService, useValue: vocabularyServiceStub }, { provide: DsDynamicTypeBindRelationService, useClass: DsDynamicTypeBindRelationService }, { provide: SubmissionObjectDataService, useValue: {} }, @@ -185,9 +190,15 @@ describe('DsDynamicRelationGroupComponent test suite', () => { { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) + .overrideComponent(DsDynamicRelationGroupComponent, { + remove: { + imports: [FormComponent], + }, + }) .compileComponents(); })); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 6236028f2e3..84cb3a60240 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -11,7 +11,7 @@ aria-hidden="true"> + [attr.aria-label]="model.label">
{ + key: key, preventDefault: () => { }, stopPropagation: () => { }, }; @@ -278,8 +278,8 @@ describe('DsDynamicTagComponent test suite', () => { expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); }); - it('should add an item on ENTER or key press is \',\' or \';\'', fakeAsync(() => { - let event = createKeyUpEvent(13); + it('should add an item on ENTER or key press is \',\'', fakeAsync(() => { + let event = createKeyUpEvent('Enter'); tagComp.currentValue = 'test value'; tagFixture.detectChanges(); @@ -290,7 +290,7 @@ describe('DsDynamicTagComponent test suite', () => { expect(tagComp.model.value).toEqual(['test value']); expect(tagComp.currentValue).toBeNull(); - event = createKeyUpEvent(188); + event = createKeyUpEvent(','); tagComp.currentValue = 'test value'; tagFixture.detectChanges(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index d8f12197d2a..431ca32c375 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -220,13 +220,15 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen } /** - * Add a new tag with typed text when typing 'Enter' or ',' or ';' + * Add a new tag with typed text when typing 'Enter' or ',' + * Tests the key rather than keyCode as keyCodes can vary + * based on keyboard layout (and do not consider Shift mod) * @param event the keyUp event */ onKeyUp(event) { - if (event.keyCode === 13 || event.keyCode === 188) { + if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); - // Key: 'Enter' or ',' or ';' + // Key: 'Enter' or ',' this.addTagsToChips(); event.stopPropagation(); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts new file mode 100644 index 00000000000..1d09e9fafbe --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts @@ -0,0 +1,48 @@ +import { + DynamicFormControlRelation, + MATCH_ENABLED, + MATCH_VISIBLE, + OR_OPERATOR, +} from '@ng-dynamic-forms/core'; + +/** + * Get the type bind values from the REST data for a specific field + * The return value is any[] in the method signature but in reality it's + * returning the 'relation' that'll be used for a dynamic matcher when filtering + * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' + * (OR) and a 'when' condition (the bindValues array). + * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) + * @param typeField + * @private + * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field + */ +export function getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: typeField, + value: value, + }); + }); + // match: MATCH_VISIBLE means that if true, the field / component will be visible + // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND + // when: the list of values to match against, in this case the list of strings from ... + // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part + // + // The opposing match value will be the dc.type for the workspace item + // + // MATCH_ENABLED is now also returned, so that hidden type-bound fields that are 'required' + // do not trigger false validation errors + return [ + { + match: MATCH_ENABLED, + operator: OR_OPERATOR, + when: bindValues, + }, + { + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues, + }, + ]; +} 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 590d5f564e9..2c47a4a30c5 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -2,12 +2,7 @@ import { Inject, InjectionToken, } from '@angular/core'; -import { - DynamicFormControlLayout, - DynamicFormControlRelation, - MATCH_VISIBLE, - OR_OPERATOR, -} from '@ng-dynamic-forms/core'; +import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import uniqueId from 'lodash/uniqueId'; @@ -28,6 +23,7 @@ import { DynamicRowArrayModel, DynamicRowArrayModelConfig, } from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { getTypeBindRelations } from '../ds-dynamic-form-ui/type-bind.utils'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../models/relationship-options.model'; @@ -98,7 +94,7 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, - typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, + typeBindRelations: isNotEmpty(this.configData.typeBind) ? getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField) : null, groupFactory: () => { let model; @@ -310,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); @@ -327,7 +324,7 @@ export abstract class FieldParser { // If typeBind is configured if (isNotEmpty(this.configData.typeBind)) { - (controlModel as DsDynamicInputModel).typeBindRelations = this.getTypeBindRelations(this.configData.typeBind, + (controlModel as DsDynamicInputModel).typeBindRelations = getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField); } @@ -356,38 +353,6 @@ export abstract class FieldParser { ); } - /** - * Get the type bind values from the REST data for a specific field - * The return value is any[] in the method signature but in reality it's - * returning the 'relation' that'll be used for a dynamic matcher when filtering - * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' - * (OR) and a 'when' condition (the bindValues array). - * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) - * @param typeField - * @private - * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field - */ - private getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: typeField, - value: value, - }); - }); - // match: MATCH_VISIBLE means that if true, the field / component will be visible - // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND - // when: the list of values to match against, in this case the list of strings from ... - // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part - // - // The opposing match value will be the dc.type for the workspace item - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; - } - protected hasRegex() { return hasValue(this.configData.input.regex); } diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 85e32e66ea8..9cb9328088d 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -16,8 +16,8 @@
@@ -25,8 +25,8 @@
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 83266b1b5f6..722015da951 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts @@ -10,10 +10,13 @@ import { merge, mergeMap, scan, + tap, } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; import { + getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload, } from '../../../core/shared/operators'; @@ -90,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 * @@ -197,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; @@ -208,9 +235,22 @@ export class VocabularyTreeviewService { this.nodeMap = new Map(); this.dataChange.next([]); - this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, new PageInfo()).pipe( + 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((result: VocabularyEntry[]) => result.length > 0 ? result : observableOf(null)), mergeMap((entry: VocabularyEntry) => this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( getFirstSucceededRemoteDataPayload(), 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/item.mock.ts b/src/app/shared/mocks/item.mock.ts index 7f723bfd61a..c8a8c19ede4 100644 --- a/src/app/shared/mocks/item.mock.ts +++ b/src/app/shared/mocks/item.mock.ts @@ -293,4 +293,55 @@ export const ItemMock: Item = Object.assign(new Item(), { }, ), }); + +export const NonDiscoverableItemMock: Item = Object.assign(new Item(), { + handle: '10673/7', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: false, + isWithdrawn: false, + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([ + MockOriginalBundle, + ])), + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f358', + }, + }, + id: '0ec7ff22-f211-40ab-a69e-c819b0b1f358', + uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f358', + type: 'item', + metadata: { + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z', + }, + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z', + }, + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26', + }, + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/7', + }, + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Test Non-Discoverable', + }, + ], + }, +}); /* eslint-enable @typescript-eslint/no-shadow */ 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/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 583b857063b..8398acdbcb9 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -94,7 +94,7 @@ export class ItemListPreviewComponent implements OnInit { ngOnInit(): void { this.showThumbnails = this.appConfig.browseBy.showThumbnails; - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item, true); } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index 20e11953030..b8318b1b15f 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -23,22 +23,22 @@ [innerHTML]="dsoTitle"> - - ( - , - ) + + ( + , + ) - - + + ; -
+
+ [innerHTML]="abstract">
diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index c4251c3597f..d45eea80827 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -41,7 +41,7 @@ export class SearchResultListElementComponent, K exten ngOnInit(): void { if (hasValue(this.object)) { this.dso = this.object.indexableObject; - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso, true); } } @@ -49,11 +49,13 @@ export class SearchResultListElementComponent, K exten * Gets all matching metadata string values from hitHighlights or dso metadata. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute. Defaults to `true` because we + * always use `[innerHTML]` in the templates to render metadata due to the hit highlights. * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - const dsoMetadata: string[] = Metadata.allValues([this.dso.metadata], keyOrKeys); - const highlights: string[] = Metadata.allValues([this.object.hitHighlights], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + const dsoMetadata: string[] = Metadata.allValues(this.dso.metadata, keyOrKeys, undefined, undefined, escapeHTML); + const highlights: string[] = Metadata.allValues({}, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); const removedHighlights: string[] = highlights.map(str => str.replace(/<\/?em>/g, '')); for (let i = 0; i < removedHighlights.length; i++) { const index = dsoMetadata.indexOf(removedHighlights[i]); @@ -68,10 +70,12 @@ export class SearchResultListElementComponent, K exten * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute. Defaults to `true` because we + * always use `[innerHTML]` in the templates to render metadata due to the hit highlights. * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[]): string { - return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + firstMetadataValue(keyOrKeys: string | string[], escapeHTML = true): string { + return Metadata.firstValue(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts index e65883cd3ec..882cc6df83c 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts @@ -87,7 +87,7 @@ export class SidebarSearchListElementComponent, K exte getParentTitle(): Observable { return this.getParent().pipe( map((parentRD: RemoteData) => { - return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload) : undefined; + return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload, true) : undefined; }), ); } diff --git a/src/app/shared/object-select/collection-select/collection-select.component.html b/src/app/shared/object-select/collection-select/collection-select.component.html index 03491e74912..a2f2176986a 100644 --- a/src/app/shared/object-select/collection-select/collection-select.component.html +++ b/src/app/shared/object-select/collection-select/collection-select.component.html @@ -36,7 +36,7 @@ [ngClass]="{'btn-danger': dangerConfirm, 'btn-primary': !dangerConfirm}" [dsBtnDisabled]="selectedIds?.length === 0" (click)="confirmSelected()"> - {{confirmButton | translate}} + {{confirmButton | translate}}
diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.html b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.html index 9f4821e7802..7ee4ae0ea22 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.html +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.html @@ -1,10 +1,15 @@ -ORCID {{ orcidTooltip }} + + ORCID {{ orcidTooltip }} + {{ orcidTooltip }} diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.scss b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.scss index 6a1c259e18a..8d4ee112524 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.scss +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.scss @@ -4,8 +4,4 @@ .orcid-icon { height: 1.2rem; - - &.not-authenticated { - filter: grayscale(100%); - } } diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts index dd47fd918bb..f6df5df38aa 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.spec.ts @@ -9,7 +9,9 @@ import { import { By } from '@angular/platform-browser'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; import { MetadataValue } from '../../core/shared/metadata.models'; import { OrcidBadgeAndTooltipComponent } from './orcid-badge-and-tooltip.component'; @@ -19,6 +21,9 @@ describe('OrcidBadgeAndTooltipComponent', () => { let translateService: TranslateService; beforeEach(async () => { + const configurationDataServiceStub = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']); + configurationDataServiceStub.findByPropertyName.and.returnValue(of({})); + await TestBed.configureTestingModule({ imports: [ OrcidBadgeAndTooltipComponent, @@ -28,6 +33,7 @@ describe('OrcidBadgeAndTooltipComponent', () => { ], providers: [ { provide: TranslateService, useValue: { instant: (key: string) => key } }, + { provide: ConfigurationDataService, useValue: configurationDataServiceStub }, ], }).compileComponents(); @@ -61,11 +67,16 @@ describe('OrcidBadgeAndTooltipComponent', () => { expect(badgeIcon).toBeTruthy(); }); - it('should display the ORCID icon in greyscale if there is no authenticated timestamp', () => { + it('should display the filled green ORCID icon if there is an authenticated timestamp', () => { + const badgeIcon = fixture.debugElement.query(By.css('img[data-test="orcidIcon"]')); + expect(badgeIcon.nativeElement.getAttribute('src')).toEqual('assets/images/orcid.logo.icon.svg'); + }); + + it('should display the unfilled green ORCID icon if there is no authenticated timestamp', () => { component.authenticatedTimestamp = null; fixture.detectChanges(); const badgeIcon = fixture.debugElement.query(By.css('img[data-test="orcidIcon"]')); - expect(badgeIcon.nativeElement.classList).toContain('not-authenticated'); + expect(badgeIcon.nativeElement.getAttribute('src')).toEqual('assets/images/orcid.logo.unauth.icon.svg'); }); }); diff --git a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts index 6e8ba7100f3..06f5da5f427 100644 --- a/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts +++ b/src/app/shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component.ts @@ -1,4 +1,5 @@ import { + AsyncPipe, NgClass, NgIf, } from '@angular/common'; @@ -9,8 +10,15 @@ import { } from '@angular/core'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; +import { + map, + Observable, +} from 'rxjs'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; import { MetadataValue } from '../../core/shared/metadata.models'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; /** * Component to display an ORCID badge with a tooltip. @@ -20,6 +28,7 @@ import { MetadataValue } from '../../core/shared/metadata.models'; selector: 'ds-orcid-badge-and-tooltip', standalone: true, imports: [ + AsyncPipe, NgIf, NgbTooltipModule, NgClass, @@ -44,12 +53,18 @@ export class OrcidBadgeAndTooltipComponent implements OnInit { */ orcidTooltip: string; + /** + * Observable for the full ORCID URL + */ + orcidUrl$: Observable; + /** * Constructor to inject the TranslateService. * @param translateService - Service for translation. */ constructor( private translateService: TranslateService, + private configurationService: ConfigurationDataService, ) { } /** @@ -60,6 +75,35 @@ export class OrcidBadgeAndTooltipComponent implements OnInit { this.orcidTooltip = this.authenticatedTimestamp ? this.translateService.instant('person.orcid-tooltip.authenticated', { orcid: this.orcid.value }) : this.translateService.instant('person.orcid-tooltip.not-authenticated', { orcid: this.orcid.value }); + this.orcidUrl$ = this.buildOrcidUrl(); } + /** + * Build the full ORCID URL from configuration and metadata value + */ + private buildOrcidUrl(): Observable { + + + const baseUrl$ = this.configurationService + .findByPropertyName('orcid.domain-url') + .pipe( + getFirstSucceededRemoteDataPayload(), + map((property: ConfigurationProperty) => + property?.values?.length > 0 ? property.values[0] : null, + ), + ); + + + return baseUrl$.pipe( + map(baseUrl => { + if (!baseUrl || !this.orcid?.value) { + return ''; + } + + const cleanBaseUrl = baseUrl.replace(/\/$/, ''); + const cleanOrcidId = this.orcid.value.replace(/^\//, ''); + return `${cleanBaseUrl}/${cleanOrcidId}`; + }), + ); + } } diff --git a/src/app/shared/resource-policies/entry/resource-policy-entry.component.html b/src/app/shared/resource-policies/entry/resource-policy-entry.component.html index cd3b5b932f6..0466db64ce0 100644 --- a/src/app/shared/resource-policies/entry/resource-policy-entry.component.html +++ b/src/app/shared/resource-policies/entry/resource-policy-entry.component.html @@ -15,7 +15,7 @@ {{entry.policy.name}} {{entry.policy.policyType}} -{{entry.policy.action}} +{{getActionDisplayLabel(entry.policy.action)}} {{ epersonName$ | async }} diff --git a/src/app/shared/resource-policies/entry/resource-policy-entry.component.spec.ts b/src/app/shared/resource-policies/entry/resource-policy-entry.component.spec.ts index 5c80afdbc9d..276fded8608 100644 --- a/src/app/shared/resource-policies/entry/resource-policy-entry.component.spec.ts +++ b/src/app/shared/resource-policies/entry/resource-policy-entry.component.spec.ts @@ -220,5 +220,17 @@ describe('ResourcePolicyEntryComponent', () => { checkbox.triggerEventHandler('ngModelChange', false); expect(comp.toggleCheckbox.emit).toHaveBeenCalledWith(false); }); + + it('should return "DELETE" for ActionType.DELETE', () => { + expect(comp.getActionDisplayLabel(ActionType.DELETE)).toBe('DELETE'); + }); + + it('should return string value for other action types', () => { + expect(comp.getActionDisplayLabel(ActionType.READ)).toBe('READ'); + expect(comp.getActionDisplayLabel(ActionType.WRITE)).toBe('WRITE'); + expect(comp.getActionDisplayLabel(ActionType.ADD)).toBe('ADD'); + expect(comp.getActionDisplayLabel(ActionType.REMOVE)).toBe('REMOVE'); + expect(comp.getActionDisplayLabel(ActionType.ADMIN)).toBe('ADMIN'); + }); }); }); diff --git a/src/app/shared/resource-policies/entry/resource-policy-entry.component.ts b/src/app/shared/resource-policies/entry/resource-policy-entry.component.ts index 491ebffb86a..01e8dbfd589 100644 --- a/src/app/shared/resource-policies/entry/resource-policy-entry.component.ts +++ b/src/app/shared/resource-policies/entry/resource-policy-entry.component.ts @@ -30,6 +30,7 @@ import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { RemoteData } from '../../../core/data/remote-data'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { Group } from '../../../core/eperson/models/group.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { @@ -110,6 +111,20 @@ export class ResourcePolicyEntryComponent implements OnInit { return isNotEmpty(date) ? dateToString(stringToNgbDateStruct(date)) : ''; } + /** + * Returns the display label for the action type. + * Shows 'DELETE' instead of 'OBSOLETE (DELETE)' for better UX. + * + * @param action the ActionType value + * @return a string with the display label + */ + getActionDisplayLabel(action: ActionType): string { + if (action === ActionType.DELETE) { + return 'DELETE'; + } + return String(action); + } + /** * Redirect to resource policy editing page */ diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts index deaef6c611f..9136ccfaee6 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts @@ -21,11 +21,15 @@ import { } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DYNAMIC_FORM_CONTROL_MAP_FN } from '@ng-dynamic-forms/core'; +import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { getTestScheduler } from 'jasmine-marbles'; import { NgxMaskModule } from 'ngx-mask'; -import { of as observableOf } from 'rxjs'; +import { + Observable, + of as observableOf, +} from 'rxjs'; import { delay } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { @@ -57,6 +61,8 @@ import { DsDynamicTypeBindRelationService } from '../../form/builder/ds-dynamic- import { FormBuilderService } from '../../form/builder/form-builder.service'; import { FormComponent } from '../../form/form.component'; import { FormService } from '../../form/form.service'; +import { LiveRegionService } from '../../live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../live-region/live-region.service.stub'; import { getMockFormService } from '../../mocks/form-service.mock'; import { getMockRequestService } from '../../mocks/request.service.mock'; import { RouterMock } from '../../mocks/router.mock'; @@ -237,6 +243,8 @@ describe('ResourcePolicyFormComponent test suite', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn }, provideMockStore({}), + provideMockActions(() => new Observable()), + { provide: LiveRegionService, useValue: getLiveRegionServiceStub() }, ], schemas: [ NO_ERRORS_SCHEMA, diff --git a/src/app/shared/resource-policies/form/resource-policy-form.model.ts b/src/app/shared/resource-policies/form/resource-policy-form.model.ts index 98ecd678278..221fd2314cf 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.model.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.model.ts @@ -39,6 +39,10 @@ const policyActionList: DynamicFormOptionConfig[] = [ label: ActionType.WRITE.toString(), value: ActionType.WRITE, }, + { + label: ActionType.ADD.toString(), + value: ActionType.ADD, + }, { label: ActionType.REMOVE.toString(), value: ActionType.REMOVE, @@ -48,7 +52,7 @@ const policyActionList: DynamicFormOptionConfig[] = [ value: ActionType.ADMIN, }, { - label: ActionType.DELETE.toString(), + label: 'DELETE', value: ActionType.DELETE, }, { diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 87303439527..d57004554e5 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -5,6 +5,7 @@
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-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index c97af4bc250..1c60c59bcd5 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -2,7 +2,8 @@ [tabIndex]="-1" [routerLink]="[searchLink]" [queryParams]="addQueryParams$ | async" - (click)="announceFilter(); filterService.minimizeAll()"> + (click)="announceFilter(); filterService.minimizeAll()" + rel="nofollow">