diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 249b4d84d78..4ea6134dcab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,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 @@ -59,11 +60,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 }} @@ -88,7 +89,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,7 +116,7 @@ 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 }} @@ -134,7 +135,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 @@ -149,7 +150,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 }} @@ -158,7 +159,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 }} diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index 1e16f8fcf86..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 + uses: github/codeql-action/analyze@v4 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 e1e72cbf43f..3f18778e550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,33 @@ # 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 RUN apk add tzdata RUN yarn build:prod RUN npm install pm2 -g +EXPOSE 4000 CMD /bin/sh -c "pm2-runtime start docker/dspace-ui.json > /dev/null 2> /dev/null" - diff --git a/Dockerfile.dist b/Dockerfile.dist index 4c1fc659f4d..75160c90eaf 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -4,31 +4,46 @@ # Test build: # docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_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/ - -# Set memory limit for build process - Angular builds require more memory +# 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-dtq.md b/README-dtq.md index 5115d7b2901..5c1c8a1630d 100644 --- a/README-dtq.md +++ b/README-dtq.md @@ -121,6 +121,34 @@ DSPACE_NAMESPACE # The namespace of the angular application DSPACE_SSL # Whether the angular application uses SSL [true/false] ``` +**Required since 7.6.7 - the public UI URL:** + +```bash +UI_URL # Public URL this UI answers on, e.g. https://lindat.example.org/repository +DSPACE_UI_BASEURL # Optional override; defaults to UI_URL +``` + +`ui.baseUrl` used to be derived from `DSPACE_HOST`/`DSPACE_PORT`/`DSPACE_SSL`. 7.6.7 replaced that with a +hardcoded `http://localhost:4000` default and dropped the Host-header trust, so those variables no longer +affect it. Note this was never a *working* public URL in Docker either - the compose file pins +`DSPACE_UI_HOST: dspace-angular`, so the derived value used to be `http://dspace-angular:4000/`. The change is +that a wrong value is now the same wrong value everywhere instead of an internal hostname. + +It matters because `ui.baseUrl` feeds more than it looks: legacy `/bitstream/handle/...` 301 redirects, the +`robots.txt` `Sitemap:` line, and the Google Scholar `citation_pdf_url` / `citation_abstract_html_url` meta +tags on every item page. All of them fail silently, with no log line. + +`UI_URL` already existed in the env files and already feeds the backend's `dspace.ui.url` +(`docker-compose-rest.yml`, `cli.yml`) - the two are meant to be the same value, which is exactly what +`config.example.yml` says about `ui.baseUrl`. `docker/docker-compose.yml` therefore defaults +`DSPACE_UI_BASEURL` to `UI_URL`, and refuses to start if neither is set. + +> **If the UI is served under a namespace** (e.g. `/repository`), set `DSPACE_UI_NAMESPACE` to the same path +> that appears in `UI_URL`. They are independent settings with no cross-check, and the legacy-bitstream +> redirect builds its target from `nameSpace + route` resolved against `baseUrl` - so a namespaced `UI_URL` +> with `DSPACE_UI_NAMESPACE` left at `/` produces a URL without the prefix, i.e. a 404 on every legacy +> citation link. + All other settings can be set using the following convention for naming the environment variables: 1. replace all `.` with `_` diff --git a/README.md b/README.md index 0be2f23c29d..bb88625a463 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/build-scripts/run/envs/.local b/build-scripts/run/envs/.local index cfa0874bc35..d7ef8923896 100644 --- a/build-scripts/run/envs/.local +++ b/build-scripts/run/envs/.local @@ -1,2 +1,3 @@ DSPACE_UI_HOST=0.0.0.0 DSPACE_UI_IMAGE=dspace-angular +UI_URL=http://localhost:4000 diff --git a/config/config.example.yml b/config/config.example.yml index 8b56711c7d2..8586a382653 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). @@ -84,6 +87,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 @@ -379,6 +385,7 @@ themes: # - name: BASE_THEME_NAME # - name: dspace + prefetch: true headTags: - tagName: link attributes: diff --git a/cypress/e2e/community-list.cy.ts b/cypress/e2e/community-list.cy.ts index c371f6ceae7..129e657391e 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -2,6 +2,86 @@ import { testA11y } from 'cypress/support/utils'; describe('Community List Page', () => { + function validateHierarchyLevel(currentLevel = 1): void { + // Find all elements with the current aria-level + cy.get(`ds-community-list cdk-tree-node.expandable-node[aria-level="${currentLevel}"]`).should('exist').then(($nodes) => { + let sublevelExists = false; + cy.wrap($nodes).each(($node) => { + // Check if the current node has an expand button and click it + if ($node.find('[data-test="expand-button"]').length) { + sublevelExists = true; + cy.wrap($node).find('[data-test="expand-button"]').click(); + } + }).then(() => { + // After expanding all buttons, validate if a sublevel exists + if (sublevelExists) { + const nextLevelSelector = `ds-community-list cdk-tree-node.expandable-node[aria-level="${currentLevel + 1}"]`; + cy.get(nextLevelSelector).then(($nextLevel) => { + if ($nextLevel.length) { + // Recursively validate the next level + validateHierarchyLevel(currentLevel + 1); + } + }); + } + }); + }); + } + + beforeEach(() => { + cy.visit('/community-list'); + + // tag must be loaded + cy.get('ds-community-list-page').should('be.visible'); + + // tag must be loaded + cy.get('ds-community-list').should('be.visible'); + }); + + it('should expand community/collection hierarchy', () => { + // Execute Hierarchy levels validation recursively + validateHierarchyLevel(1); + }); + + it('should display community/collections name with item count', () => { + // Open every + cy.get('[data-test="expand-button"]').click({ multiple: true }); + cy.wait(300); + + // A first must be found and validate that tag (community name) and tag (item count) exists in it + cy.get('ds-community-list').find('cdk-tree-node.expandable-node').then(($nodes) => { + cy.wrap($nodes).each(($node) => { + cy.wrap($node).find('a').should('exist'); + cy.wrap($node).find('span').should('exist'); + }); + }); + }); + + it('should enable "show more" button when 20 top-communities or more are presents', () => { + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').then(($nodes) => { + //Validate that there are 20 or more top-community elements + if ($nodes.length >= 20) { + //Validate that "show more" button is visible and then click on it + cy.get('[data-test="show-more-button"]').should('be.visible'); + } else { + cy.get('[data-test="show-more-button"]').should('not.exist'); + } + }); + }); + + it('should show 21 or more top-communities if click "show more" button', () => { + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').then(($nodes) => { + //Validate that there are 20 or more top-community elements + if ($nodes.length >= 20) { + //Validate that "show more" button is visible and then click on it + cy.get('[data-test="show-more-button"]').click(); + cy.wait(300); + cy.get('ds-community-list').find('cdk-tree-node.expandable-node[aria-level="1"]').should('have.length.at.least', 21); + } else { + cy.get('[data-test="show-more-button"]').should('not.exist'); + } + }); + }); + it('should pass accessibility tests', () => { cy.visit('/community-list'); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index 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/docker/README.md b/docker/README.md index 994cc98736e..3a1803f4b9e 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 7 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-7_x . @@ -35,7 +36,7 @@ docker push dspace/dspace-angular:dspace-7_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 @@ -57,6 +58,12 @@ A default/demo version of this image is built *automatically*. - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. +> **Required since 7.6.7:** every recipe below that uses `docker/docker-compose.yml` needs the public UI URL. +> Set `UI_URL` (or `DSPACE_UI_BASEURL`) in your env file - `build-scripts/run/envs/.default` and `.local` +> already do. Without it compose refuses to start, because 7.6.7 no longer derives `ui.baseUrl` from +> `DSPACE_UI_HOST`/`PORT`/`SSL` and would otherwise serve `http://localhost:4000` in redirects, `robots.txt` +> and the Google Scholar citation meta tags. See `README-dtq.md` for the namespace caveat. + ## To refresh / pull DSpace images from Dockerhub ``` docker-compose -f docker/docker-compose.yml pull diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index c120243a337..d001515049f 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -20,7 +20,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 007309545bc..ebd20b90364 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -16,9 +16,9 @@ services: dspace-cli: environment: - - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip - - ADMIN_EMAIL=dspace.admin.dev@dataquest.sk - - AIPDIR=/tmp/aip-dir + - AIPZIP=${AIPZIP:-https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip} + - ADMIN_EMAIL=${ADMIN_EMAIL:-dspace.admin.dev@dataquest.sk} + - AIPDIR=${AIPDIR:-/tmp/aip-dir} entrypoint: - /bin/bash - '-c' diff --git a/docker/cli.yml b/docker/cli.yml index e9758830e49..339cc9c59c2 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 d7" means it will be named "d7_dspacenet") # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) - default: + dspacenet: name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet external: true services: @@ -35,11 +35,9 @@ services: dspace__P__ui__P__url: ${UI_URL:-http://127.0.0.1:4000} 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} # S3 assetstore__P__index__P__primary: ${S3_STORAGE:-0} assetstore__P__s3__P__enabled: ${S3_ENABLED:-false} @@ -52,14 +50,14 @@ services: assetstore__P__s3__P__pathStyleAccessEnabled: ${S3_PATH_STYLE_ACCESS:-false} assetstore__P__s3__P__endpoint: ${S3_ENDPOINT:-} + networks: + - dspacenet volumes: - "assetstore:/dspace/assetstore" - dspace_cli_logs:/dspace/log - ./local.cfg:/dspace/config/local.cfg 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 cc5620423e2..ec0ae8dff18 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -18,7 +18,7 @@ services: environment: # 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 - - 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 diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index b81366e6861..d3720118f34 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -26,9 +26,9 @@ services: dspace__P__server__P__url: ${REST_URL:-http://127.0.0.1:8080/server} dspace__P__ui__P__url: ${UI_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' @@ -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 @@ -91,8 +89,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 88e5be16a5d..967499666c2 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: demo.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-7_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 8bafe458d8b..e158f1ef811 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -36,7 +36,7 @@ services: dspace__P__server__P__url: ${REST_URL:-http://127.0.0.1:8080/server} dspace__P__ui__P__url: ${UI_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 # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ea0e5f30f1b..d3b6cb55ba2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,6 +22,16 @@ services: # Use only `4000`, not the {UI_PORT} from the .env because in the container it is always `4000` DSPACE_UI_PORT: 4000 DSPACE_UI_NAMESPACE: ${DSPACE_UI_NAMESPACE:-/} + # Public URL this UI answers on. Since 7.6.7 `ui.baseUrl` is no longer derived from + # DSPACE_UI_HOST/PORT/SSL, so it has to be set explicitly - otherwise legacy + # /bitstream/handle/... redirects, the robots.txt Sitemap and the Google Scholar + # citation_* meta tags all point at localhost, silently. + # Defaults to UI_URL, which the env files already define and which also feeds the backend's + # `dspace.ui.url` (see docker-compose-rest.yml) - the two are meant to be the same value. + # If neither is set the stack refuses to start rather than serving wrong URLs. + # NOTE: if the UI is served under a namespace, DSPACE_UI_NAMESPACE must match the path in + # this URL, otherwise the legacy-bitstream redirect drops the prefix and 404s. + DSPACE_UI_BASEURL: ${DSPACE_UI_BASEURL:-${UI_URL:?set UI_URL (or DSPACE_UI_BASEURL) in the env file to the public UI URL, e.g. https://lindat.example.org/repository}} DSPACE_REST_SSL: ${DSPACE_SSL:-false} DSPACE_REST_HOST: ${DSPACE_HOST:-localhost} DSPACE_REST_PORT: ${DSPACE_REST_PORT:-8080} diff --git a/package.json b/package.json index 565c0510999..99ad9d257b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "7.6.5", + "version": "7.6.7", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -64,7 +64,6 @@ "@angular/platform-browser-dynamic": "^15.2.10", "@angular/platform-server": "^15.2.10", "@angular/router": "^15.2.10", - "@babel/runtime": "7.27.6", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^15.0.0", @@ -78,37 +77,34 @@ "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "angular-idle-preload": "3.0.0", "angulartics2": "^12.2.1", - "axios": "^1.10.0", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "chart.js": "4.3.3", "cli-progress": "^3.12.0", "colors": "^1.4.0", "compression": "^1.8.1", - "ng2-charts": "4.1.1", - "chart.js": "4.3.3", "cookie-parser": "1.4.7", - "core-js": "^3.42.0", + "core-js": "^3.49.0", "date-fns": "^2.30.0", "d3": "^7.9.0", "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.21", "lindat-common": "^1.5.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "lru-cache": "^7.14.1", "markdown-it": "^13.0.2", "markdown-it-mathjax3": "^4.3.2", @@ -124,12 +120,13 @@ "ngx-skeleton-loader": "^7.0.0", "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.1.0", + "node-html-parser": "^7.1.0", "nouislider": "^15.8.1", "pem": "1.14.8", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "sanitize-html": "^2.17.0", - "sortablejs": "1.15.6", + "sanitize-html": "^2.17.4", + "sortablejs": "1.15.7", "uuid": "^8.3.2", "zone.js": "~0.13.3" }, @@ -143,7 +140,6 @@ "@angular-eslint/template-parser": "15.2.1", "@angular/cli": "^16.2.16", "@angular/compiler-cli": "^15.2.10", - "@angular/language-service": "^15.2.10", "@cypress/schematic": "^1.5.0", "@fortawesome/fontawesome-free": "^6.7.2", "@material-ui/core": "^4.12.4", @@ -159,24 +155,24 @@ "@types/d3-dispatch": "3.0.6", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.17", + "@types/lodash": "^4.17.24", "@types/node": "^14.18.63", - "@types/sanitize-html": "^2.16.0", + "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.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", + "csstype": "^3.2.3", "cypress": "^13.17.0", - "cypress-axe": "^1.6.0", + "cypress-axe": "^1.7.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.5.0", "eslint-plugin-import": "^2.32.0", "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-unused-imports": "^2.0.0", "express-static-gzip": "^2.2.0", @@ -188,7 +184,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": "~13.1.7", "nodemon": "^2.0.22", "postcss": "^8.5", @@ -197,16 +193,15 @@ "postcss-preset-env": "^7.4.2", "prop-types": "^15.8.1", "react": "^16.14.0", - "react-copy-to-clipboard": "^5.1.0", + "react-copy-to-clipboard": "^5.1.1", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "sass": "~1.89.2", + "sass": "~1.99.0", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", "typescript": "~4.8.4", "webpack": "5.76.1", - "webpack-cli": "^4.2.0", - "webpack-dev-server": "^4.15.2" + "webpack-cli": "^4.2.0" } } diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 96ba0d40105..bb0295ea50a 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 e7b13aef522..d4283639fc5 100644 --- a/server.ts +++ b/server.ts @@ -26,7 +26,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'; @@ -53,6 +52,7 @@ import { ServerAppModule } from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; +import { ServerHashedFileMapping } from './src/modules/dynamic-hash/hashed-file-mapping.server'; import { logStartupMessage } from './startup-message'; import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model'; import { SsrExcludePatterns } from './src/config/universal-config.interface'; @@ -69,7 +69,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; @@ -167,7 +171,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, }); }); @@ -322,7 +326,7 @@ function clientSideRender(req, res) { html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); } - res.send(html); + res.set('Cache-Control', 'no-cache, no-store').send(html); } @@ -333,7 +337,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(); } @@ -572,8 +580,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(); @@ -659,13 +667,15 @@ 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({ - error: error.message + res.status(error?.response?.status || 503).send({ + error: error.message, }); }); } diff --git a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index 81ecdae7c06..786806988bf 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -38,6 +38,8 @@ import { NoContent } from '../../../core/shared/NoContent.model'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { RouteService } from '../../../core/services/route.service'; describe('GroupFormComponent', () => { let component: GroupFormComponent; @@ -230,6 +232,7 @@ describe('GroupFormComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: Router, useValue: router }, { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: RouteService, useValue: routeServiceStub }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 19b604358cc..f9e14b1ee73 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -46,6 +46,7 @@ import { ValidateGroupExists } from './validators/group-exists.validator'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { environment } from '../../../../environments/environment'; import { getGroupEditRoute, getGroupsRoute } from '../../access-control-routing-paths'; +import { RouteService } from '../../../core/services/route.service'; @Component({ selector: 'ds-group-form', @@ -155,6 +156,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { public requestService: RequestService, protected changeDetectorRef: ChangeDetectorRef, public dsoNameService: DSONameService, + protected routeService: RouteService, ) { } @@ -267,7 +269,11 @@ export class GroupFormComponent implements OnInit, OnDestroy { onCancel() { this.groupDataService.cancelEditGroup(); this.cancelForm.emit(); - void this.router.navigate([getGroupsRoute()]); + this.routeService.getPreviousUrl().pipe( + take(1), + ).subscribe((previousURL) => { + void this.router.navigate([previousURL && previousURL.trim().length > 0 ? previousURL : getGroupsRoute()]); + }); } /** diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 436bde26aa2..25777a52c6c 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -47,11 +47,12 @@ import { ViewTrackerResolverService } from './statistics/angulartics/dspace/view import { HANDLE_TABLE_MODULE_PATH } from './handle-page/handle-page-routing-paths'; import { STATIC_PAGE_PATH } from './static-page/static-page-routing-paths'; import { EPIC_HANDLE_TABLE_MODULE_PATH } from './epic-handle/epic-handle-routing-paths'; +import { notAuthenticatedGuard } from './core/auth/not-authenticated.guard'; @NgModule({ imports: [ RouterModule.forRoot([ - { 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: '', @@ -103,13 +104,13 @@ import { EPIC_HANDLE_TABLE_MODULE_PATH } from './epic-handle/epic-handle-routing path: REGISTER_PATH, loadChildren: () => import('./register-page/register-page.module') .then((m) => m.RegisterPageModule), - canActivate: [SiteRegisterGuard] + canActivate: [notAuthenticatedGuard, SiteRegisterGuard] }, { path: FORGOT_PASSWORD_PATH, loadChildren: () => import('./forgot-password/forgot-password.module') .then((m) => m.ForgotPasswordModule), - canActivate: [EndUserAgreementCurrentUserGuard] + canActivate: [notAuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, { path: COMMUNITY_MODULE_PATH, @@ -179,12 +180,14 @@ import { EPIC_HANDLE_TABLE_MODULE_PATH } from './epic-handle/epic-handle-routing { path: 'login', loadChildren: () => import('./login-page/login-page.module') - .then((m) => m.LoginPageModule) + .then((m) => m.LoginPageModule), + canActivate: [notAuthenticatedGuard] }, { path: 'logout', loadChildren: () => import('./logout-page/logout-page.module') - .then((m) => m.LogoutPageModule) + .then((m) => m.LogoutPageModule), + canActivate: [AuthenticatedGuard] }, { path: 'submit', @@ -233,7 +236,8 @@ import { EPIC_HANDLE_TABLE_MODULE_PATH } from './epic-handle/epic-handle-routing }, { path: FORBIDDEN_PATH, - component: ThemedForbiddenComponent + component: ThemedForbiddenComponent, + data: { title: '403.forbidden' }, }, { path: 'statistics', @@ -286,7 +290,7 @@ import { EPIC_HANDLE_TABLE_MODULE_PATH } from './epic-handle/epic-handle-routing loadChildren: () => import('./share-submission/share-submission.module').then((m) => m.ShareSubmissionModule), canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, - { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent } + { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent, data: { title: '404.page-not-found' } }, ] } ], { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9324b9b4c31..ec8d8efeed2 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,6 +10,8 @@ import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/sto import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; +import { HashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping'; +import { BrowserHashedFileMapping } from '../modules/dynamic-hash/hashed-file-mapping.browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -115,6 +117,10 @@ const PROVIDERS = [ multi: true }, { provide: UrlSerializer, useClass: BitstreamUrlSerializer }, + { + provide: HashedFileMapping, + useClass: BrowserHashedFileMapping, + }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, ]; 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 7acb8a6c7fa..90661d9ac6c 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 @@ -4,6 +4,7 @@ import { cold } from 'jasmine-marbles'; import { EMPTY } from 'rxjs'; import { APP_CONFIG } from '../../config/app-config.interface'; +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'; @@ -173,7 +174,7 @@ describe('legacyBitstreamURLRedirectGuard', () => { TestBed.runInInjectionContext(() => { resolver(route, state).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 ba995c5e9f4..c72cd0ca98a 100644 --- a/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts +++ b/src/app/bitstream-page/legacy-bitstream-url-redirect.guard.ts @@ -10,7 +10,10 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; -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'; @@ -49,7 +52,7 @@ export const legacyBitstreamURLRedirectGuard: CanActivateFn = ( map((rd: RemoteData) => { if (rd.hasSucceeded && !rd.hasNoContent) { const nameSpace = appConfig.ui.nameSpace?.replace(/\/$/, '') || ''; - const redirectUrl = new URL(nameSpace + `/bitstreams/${rd.payload.uuid}/download`, serverHardRedirectService.getCurrentOrigin()).href; + const redirectUrl = new URL(nameSpace + getBitstreamDownloadRoute(rd.payload), serverHardRedirectService.getBaseUrl()).href; serverHardRedirectService.redirect(redirectUrl, 301); return false; } else { diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts index f64091e41f4..dab1d1601fa 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts @@ -74,8 +74,8 @@ describe('BrowseByDatePageComponent', () => { 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' }) }); @@ -110,9 +110,8 @@ describe('BrowseByDatePageComponent', () => { fixture = TestBed.createComponent(BrowseByDatePageComponent); 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-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts index cca56647d81..bf64b835619 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -75,7 +75,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { this.currentSort$, ]).subscribe(([params, currentPage, currentSort]: [Params, 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, currentPage, currentSort, this.browseId, this.fetchThumbnails); this.updatePageWithItems(searchOptions, this.value, undefined); diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts index 4ea68eaa0c7..8d3dbdc3774 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts @@ -95,8 +95,8 @@ describe('BrowseByMetadataPageComponent', () => { 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-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index 5f921667bf3..02f885c3066 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -83,15 +83,10 @@ export class BrowseByMetadataPageComponent implements OnInit, 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 @@ -177,7 +172,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { this.currentPagination$, this.currentSort$, ]).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id; this.authority = params.authority; if (typeof params.value === 'string'){ @@ -192,6 +187,8 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { if (typeof params.startsWith === 'string'){ this.startsWith = params.startsWith.trim(); + } else { + this.startsWith = ''; } if (isNotEmpty(this.value)) { diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts index 1b8eb352a39..3e7a2c6fe1f 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts @@ -62,8 +62,8 @@ describe('BrowseByTitlePageComponent', () => { 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-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts index 10968d265ff..5e498951f27 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts @@ -62,7 +62,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { this.currentSort$, ]).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { this.startsWith = +params.startsWith || params.startsWith; - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id; this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); this.updateParent(params.scope); this.updateLogo(); 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 776b82f9b4c..74b77f505eb 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 @@ -120,7 +120,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 268c92dc129..d61085b45f9 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 6bc4565682a..f6a6e3e46ac 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -68,8 +68,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 2e6ba917aae..ce9c3babb9a 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -131,7 +131,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) }); @@ -139,17 +139,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$', () => { @@ -185,7 +197,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 281355b769e..cd446c5853d 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -88,7 +88,14 @@ export class AuthEffects { switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); + catchError((error) => { + if (action.checkAgain) { + return observableOf(new CheckAuthenticationTokenCookieAction()); + } else { + return observableOf(new AuthenticatedErrorAction(error)); + } + }), + ); }) )); @@ -141,7 +148,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) => 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 594d6d8b395..ef7a7304a06 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -3,7 +3,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 b84e7a308af..267f7768c9c 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 81c75913340..de48c480d0b 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 8e4fb771c64..75dde53e5b7 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -27,9 +27,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)) { @@ -38,32 +38,33 @@ 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'); + }, }; /** * 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 @@ -72,10 +73,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 { @@ -88,27 +89,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'); } /** @@ -117,11 +119,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 405b35c1f94..cd217a6cc6f 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -56,7 +56,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. @@ -70,8 +70,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 efb6d50e848..b4367e4b84f 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -4,16 +4,18 @@ import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { COMMUNITY } from '../shared/community.resource-type'; 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'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { BitstreamDataService } from './bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { isNotEmpty } from '../../shared/empty.util'; import { FindListOptions } from './find-list-options.model'; @@ -36,6 +38,92 @@ export class CommunityDataService extends ComColDataService { super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + /** + * Get all communities the user is admin of + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getAdminAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdminAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all communities the user is authorized to add a new subcommunity or collection to + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getAddAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAddAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all communities the user is authorized to edit + * + * @param query limit the returned collection to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getEditAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findEditAuthorized'; + return this.getAuthorizedCommunity(query, options, useCachedVersionIfAvailable, reRequestOnStale, searchHref, ...linksToFollow); + } + + /** + * Get all communities the user is authorized to submit to + * + * @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 d90a6a4707b..527b9600f96 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -46,8 +46,8 @@ import { RestRequestMethod } from './rest-request-method'; import { CreateData, CreateDataImpl } from './base/create-data'; import { RequestParam } from '../cache/models/request-param.model'; import { dataService } from './base/data-service.decorator'; -import { SearchData, SearchDataImpl } from './base/search-data'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { SearchDataImpl } from './base/search-data'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; /** * An abstract service for CRUD operations on Items @@ -58,6 +58,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; + private searchData: SearchDataImpl; protected constructor( protected linkPath, @@ -76,6 +77,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); } /** @@ -323,6 +325,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 @@ -339,6 +361,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 @@ -408,7 +448,6 @@ export abstract class BaseItemDataService extends IdentifiableDataService @Injectable() @dataService(ITEM) export class ItemDataService extends BaseItemDataService { - private searchData: SearchData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, @@ -420,10 +459,5 @@ export class ItemDataService extends BaseItemDataService { protected bundleService: BundleDataService, ) { super('items', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); - this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); - } - - searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index 8ecfde0686d..9a3a9db6fd0 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -92,7 +92,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 e96126d19c8..0d3d51481f4 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController, } from '@angular/common import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RestRequestMethod } from '../data/rest-request-method'; import { LocaleService } from './locale.service'; import { LocaleInterceptor } from './locale.interceptor'; @@ -20,6 +21,10 @@ describe(`LocaleInterceptor`, () => { getLanguageCodeList: of(languageList) }); + const mockHalEndpointService = { + getRootHref: jasmine.createSpy('getRootHref'), + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -30,6 +35,7 @@ describe(`LocaleInterceptor`, () => { useClass: LocaleInterceptor, multi: true, }, + { provide: HALEndpointService, useValue: mockHalEndpointService }, { provide: LocaleService, useValue: mockLocaleService }, ], }); @@ -38,7 +44,7 @@ describe(`LocaleInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); localeService = TestBed.inject(LocaleService); - localeService.getCurrentLanguageCode.and.returnValue('en'); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); }); describe('', () => { diff --git a/src/app/core/locale/locale.interceptor.ts b/src/app/core/locale/locale.interceptor.ts index 035dad35c92..c29b81858c4 100644 --- a/src/app/core/locale/locale.interceptor.ts +++ b/src/app/core/locale/locale.interceptor.ts @@ -3,13 +3,17 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/c import { Observable } from 'rxjs'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { LocaleService } from './locale.service'; -import { mergeMap, scan } from 'rxjs/operators'; +import { mergeMap, scan, take } from 'rxjs/operators'; @Injectable() export class LocaleInterceptor implements HttpInterceptor { - constructor(private localeService: LocaleService) { + constructor( + protected halEndpointService: HALEndpointService, + protected localeService: LocaleService, + ) { } /** @@ -19,8 +23,9 @@ export class LocaleInterceptor implements HttpInterceptor { */ intercept(req: HttpRequest, next: HttpHandler): Observable> { let newReq: HttpRequest; - return this.localeService.getLanguageCodeList() + return this.localeService.getLanguageCodeList(req.url === this.halEndpointService.getRootHref()) .pipe( + take(1), scan((acc: any, value: any) => [...acc, value], []), mergeMap((languages) => { // Clone the request to add the new header. diff --git a/src/app/core/locale/locale.service.clarin.spec.ts b/src/app/core/locale/locale.service.clarin.spec.ts new file mode 100644 index 00000000000..c24fbe9cd83 --- /dev/null +++ b/src/app/core/locale/locale.service.clarin.spec.ts @@ -0,0 +1,93 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { CookieService } from '../services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { LANG_COOKIE, LocaleService } from './locale.service'; +import { AuthService } from '../auth/auth.service'; +import { NativeWindowRef } from '../services/window.service'; +import { RouteService } from '../services/route.service'; +import { routeServiceStub } from '../../shared/testing/route-service.stub'; +import { environment } from '../../../environments/environment'; + +/** + * CLARIN-only additions to LocaleService. + * + * Kept in a separate file so `locale.service.spec.ts` stays byte-identical to upstream and does not + * re-conflict on the next vanilla merge. + */ +describe('LocaleService CLARIN additions', () => { + let service: LocaleService; + let cookieService: CookieService; + let translateService: TranslateService; + let window; + let spyOnGet; + let authService; + let routeService; + let document; + + authService = jasmine.createSpyObj('AuthService', { + isAuthenticated: jasmine.createSpy('isAuthenticated'), + isAuthenticationLoaded: jasmine.createSpy('isAuthenticationLoaded'), + getAuthenticatedUserFromStore: jasmine.createSpy('getAuthenticatedUserFromStore'), + }); + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: AuthService, userValue: authService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: Document, useValue: document }, + ] + }); + })); + + beforeEach(() => { + cookieService = TestBed.inject(CookieService); + translateService = TestBed.inject(TranslateService); + routeService = TestBed.inject(RouteService); + window = new NativeWindowRef(); + document = { documentElement: { lang: 'en' } }; + service = new LocaleService(window, cookieService, translateService, authService, routeService, document); + spyOnGet = spyOn(cookieService, 'get'); + }); + + describe('getCurrentLanguageCodeSync', () => { + it('should return the language the UI is currently rendering in', () => { + translateService.use('cs'); + expect(service.getCurrentLanguageCodeSync()).toBe('cs'); + }); + + it('should fall back to the cookie before any language has been applied', () => { + spyOnGet.withArgs(LANG_COOKIE).and.returnValue('de'); + expect(service.getCurrentLanguageCodeSync()).toBe('de'); + }); + + it('should fall back to the default language when there is neither', () => { + spyOnGet.and.returnValue(undefined); + expect(service.getCurrentLanguageCodeSync()).toBe(environment.defaultLanguage); + }); + + it('should track setCurrentLanguageCode', () => { + service.setCurrentLanguageCode('cs'); + expect(service.getCurrentLanguageCodeSync()).toBe('cs'); + }); + + it('should be synchronous - the header and licence templates call it directly', () => { + translateService.use('en'); + const result: string = service.getCurrentLanguageCodeSync(); + expect(typeof result).toBe('string'); + }); + }); +}); diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts index 39356fdf970..4ca39a0960c 100644 --- a/src/app/core/locale/locale.service.spec.ts +++ b/src/app/core/locale/locale.service.spec.ts @@ -10,6 +10,9 @@ import { AuthService } from '../auth/auth.service'; import { NativeWindowRef } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { EPersonMock2 } from '../../shared/testing/eperson.mock'; describe('LocaleService test suite', () => { let service: LocaleService; @@ -25,7 +28,8 @@ describe('LocaleService test suite', () => { authService = jasmine.createSpyObj('AuthService', { isAuthenticated: jasmine.createSpy('isAuthenticated'), - isAuthenticationLoaded: jasmine.createSpy('isAuthenticationLoaded') + isAuthenticationLoaded: jasmine.createSpy('isAuthenticationLoaded'), + getAuthenticatedUserFromStore: jasmine.createSpy('getAuthenticatedUserFromStore'), }); const langList = ['en', 'xx', 'de']; @@ -62,33 +66,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'] }); + }); }); }); @@ -120,14 +171,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 5c080d8c16c..40ee87129ef 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -1,12 +1,12 @@ -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, OnDestroy } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty, hasValue } from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; import { environment } from '../../../environments/environment'; import { AuthService } from '../auth/auth.service'; -import { combineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { map, mergeMap, take } from 'rxjs/operators'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; @@ -28,13 +28,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, @@ -48,20 +50,43 @@ 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(); 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); + } + + /** + * CLARIN: the language the UI is currently rendering in, available synchronously. + * + * This is deliberately NOT a synchronous variant of {@link getCurrentLanguageCode}. That method + * *negotiates* the initial language against the authenticated user's profile and the browser's + * Accept-Language list, which requires the auth state and is therefore asynchronous. + * + * The fork's header and licence components need something different and much simpler: the + * language currently in effect, inside synchronous template getters. Once + * {@link setCurrentLanguageCode} has run, that is whatever `translate.use()` last applied. + * + * @returns {string} The active language code + */ + getCurrentLanguageCodeSync(): string { + const lang = this.translate.currentLang || this.getLanguageCodeFromCookie(); + return isNotEmpty(lang) ? lang : environment.defaultLanguage; } /** @@ -69,18 +94,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) => { @@ -95,19 +118,19 @@ export class LocaleService { 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), @@ -147,11 +170,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; } /** @@ -197,4 +225,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 556619b9460..5911b74c5fa 100644 --- a/src/app/core/locale/server-locale.service.ts +++ b/src/app/core/locale/server-locale.service.ts @@ -31,7 +31,7 @@ export class ServerLocaleService extends LocaleService { * * @returns {Observable} */ - getLanguageCodeList(): Observable { + getLanguageCodeList(ignoreEPersonSettings = false): Observable { const obs$ = combineLatest([ this.authService.isAuthenticated(), this.authService.isAuthenticationLoaded() @@ -41,7 +41,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/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 76258ca839a..521de79ba37 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -12,7 +12,7 @@ import { ItemMock, MockBitstream1, MockBitstream3, - MockBitstream2 + MockBitstream2, NonDiscoverableItemMock } from '../../shared/mocks/item.mock'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginatedList } from '../data/paginated-list.model'; @@ -84,7 +84,7 @@ describe('MetadataService', () => { } } as any as Router; hardRedirectService = jasmine.createSpyObj( { - getCurrentOrigin: 'https://request.org', + getBaseUrl: 'https://request.org', }); authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: observableOf(true) @@ -119,6 +119,37 @@ describe('MetadataService', () => { ); }); + describe(`robots tag`, () => { + it(`should be set to noindex for non-discoverable items`, fakeAsync(() => { + (metadataService 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(() => { + (metadataService 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(() => { (metadataService as any).processRouteChange({ data: { diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 28fdec79ecf..935e21213aa 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -148,6 +148,8 @@ export class MetadataService { private setDSOMetaTags(): void { + this.setNoIndexTag(); + this.setTitleTag(); this.setDescriptionTag(); @@ -195,6 +197,15 @@ export class MetadataService { } + /** + * 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 */ @@ -300,7 +311,7 @@ export class MetadataService { 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); } @@ -423,7 +434,7 @@ export class MetadataService { // 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 1d8666d259e..43002acea02 100644 --- a/src/app/core/services/browser-hard-redirect.service.spec.ts +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -1,10 +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'; @@ -19,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(); }); @@ -51,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 827e83f0b7d..999ec568f9d 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, InjectionToken } from '@angular/core'; +import { environment } from '../../../environments/environment'; import { HardRedirectService } from './hard-redirect.service'; export const LocationToken = new InjectionToken('Location'); @@ -36,12 +37,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 9dc8f466b6f..6a0862b8133 100644 --- a/src/app/core/services/browser.referrer.service.spec.ts +++ b/src/app/core/services/browser.referrer.service.spec.ts @@ -15,7 +15,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 64be95d2410..7340e247ccb 100644 --- a/src/app/core/services/browser.referrer.service.ts +++ b/src/app/core/services/browser.referrer.service.ts @@ -46,7 +46,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..532bb12e5a0 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -1,15 +1,29 @@ 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', () => { 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 +31,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 +90,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 280dbd22cb9..5eafd7fc28a 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@angular/core'; import { Request, Response } from 'express'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { environment } from '../../../environments/environment'; import { HardRedirectService } from './hard-redirect.service'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; + /** * Service for performing hard redirects within the server app module */ @@ -75,12 +77,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 6f5d45544df..b54150b1b7b 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -108,33 +108,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); } /** @@ -142,26 +145,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 812e65bcbae..630a7132e59 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -44,11 +44,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; @@ -70,107 +70,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 e48b2b0c442..6b7b826bce1 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,4 +1,6 @@ -import { isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import escape from 'lodash/escape'; + +import { isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; import { MetadataMapInterface, MetadataValue, @@ -26,94 +28,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 eed93ae201c..307dcecaaf4 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -153,7 +153,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/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 f7eb2692ac0..c5aea7a46af 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..1b58919cef5 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,8 +2,8 @@ - - ; + + ; 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/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index f5581090d08..2d3fef6fd83 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -22,7 +22,7 @@ describe('HeaderComponent', () => { // Mock LocaleService const localeServiceMock = { - getCurrentLanguageCode: () => 'en' // returns default language code + getCurrentLanguageCodeSync: () => 'en' // returns default language code }; // waitForAsync beforeEach diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index f276dc9a738..72fa98ca43f 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -44,7 +44,7 @@ export class HeaderComponent implements OnInit { * @returns {string} The current language code */ getLangCode(): string { - return this.localeService.getCurrentLanguageCode(); + return this.localeService.getCurrentLanguageCodeSync(); } /** @@ -52,7 +52,7 @@ export class HeaderComponent implements OnInit { * @returns {string} The language code if Czech, empty string otherwise */ getLangCodeIfCzech(): string { - return this.localeService.getCurrentLanguageCode() === 'cs' ? 'cs' : ''; + return this.localeService.getCurrentLanguageCodeSync() === 'cs' ? 'cs' : ''; } /** @@ -61,7 +61,7 @@ export class HeaderComponent implements OnInit { * @returns {string} The translated slug if in Czech, the original slug if in English, or empty string if translation not found */ translateSlug(slug: string): string { - const currentLang = this.localeService.getCurrentLanguageCode(); + const currentLang = this.localeService.getCurrentLanguageCodeSync(); if (currentLang === 'en') { return slug; } diff --git a/src/app/item-page/clarin-license-info/clarin-license-info.component.spec.ts b/src/app/item-page/clarin-license-info/clarin-license-info.component.spec.ts index 3ccb05ad6e3..018a317d14f 100644 --- a/src/app/item-page/clarin-license-info/clarin-license-info.component.spec.ts +++ b/src/app/item-page/clarin-license-info/clarin-license-info.component.spec.ts @@ -49,7 +49,7 @@ describe('ClarinLicenseInfoComponent', () => { bypassSecurityTrustUrl: null }); localeService = jasmine.createSpyObj('LocaleService', { - getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), + getCurrentLanguageCodeSync: jasmine.createSpy('getCurrentLanguageCodeSync'), }); await TestBed.configureTestingModule({ diff --git a/src/app/item-page/clarin-license-info/clarin-license-info.component.ts b/src/app/item-page/clarin-license-info/clarin-license-info.component.ts index b2d20c0dd98..11df03ca41f 100644 --- a/src/app/item-page/clarin-license-info/clarin-license-info.component.ts +++ b/src/app/item-page/clarin-license-info/clarin-license-info.component.ts @@ -100,7 +100,7 @@ export class ClarinLicenseInfoComponent implements OnInit { * Check if current language is Czech */ isCsLocale() { - return this.localeService.getCurrentLanguageCode() === 'cs'; + return this.localeService.getCurrentLanguageCodeSync() === 'cs'; } } 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 d94abfaa9f8..644dab9e1cc 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 @@ -105,7 +105,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 234a9cde68b..4967d2546a5 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/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts index f7b9fb68e82..c0ff3d0586b 100644 --- a/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts +++ b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.spec.ts @@ -35,7 +35,13 @@ describe('TabbedRelatedEntitiesSearchComponent', () => { { provide: ActivatedRoute, useValue: { - queryParams: observableOf({ tab: mockRelationType }) + queryParams: observableOf({ tab: mockRelationType }), + snapshot: { + queryParams: { + scope: 'collection-uuid', + query: 'test', + }, + }, }, }, { provide: Router, useValue: router } @@ -72,9 +78,11 @@ describe('TabbedRelatedEntitiesSearchComponent', () => { expect(router.navigate).toHaveBeenCalledWith([], { relativeTo: (comp as any).route, queryParams: { - tab: event.nextId + tab: event.nextId, + query: 'test', + scope: 'collection-uuid', + 'spc.page': 1, }, - queryParamsHandling: 'merge' }); }); }); diff --git a/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts index 116c8c2d79f..f885e01f5bc 100644 --- a/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts +++ b/src/app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component.ts @@ -67,9 +67,11 @@ export class TabbedRelatedEntitiesSearchComponent implements OnInit { this.router.navigate([], { relativeTo: this.route, queryParams: { - tab: event.nextId + tab: event.nextId, + query: this.route.snapshot.queryParams.query, + scope: this.route.snapshot.queryParams.scope, + 'spc.page': 1, }, - queryParamsHandling: 'merge' }); } diff --git a/src/app/license-contract-page/license-contract-page.component.ts b/src/app/license-contract-page/license-contract-page.component.ts index 4849888f792..c73f0d3a672 100644 --- a/src/app/license-contract-page/license-contract-page.component.ts +++ b/src/app/license-contract-page/license-contract-page.component.ts @@ -97,7 +97,7 @@ export class LicenseContractPageComponent implements OnInit, OnDestroy { private loadAuthorizedCollections(): void { this.collectionsRD$ = this.paginationService.getFindListOptions(this.paginationId, this.config).pipe( - switchMap((config: FindListOptions) => this.collectionDataService.getAuthorizedCollection('', config, true, true, followLink('license'))) + switchMap((config: FindListOptions) => this.collectionDataService.getAuthorizedCollection('', config, true, true, 'findSubmitAuthorized', followLink('license'))) ); } } diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.html b/src/app/process-page/form/scripts-select/scripts-select.component.html index 5c161f8d8d5..8f4b6089581 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.html +++ b/src/app/process-page/form/scripts-select/scripts-select.component.html @@ -3,6 +3,7 @@
{ const validationError = fixture.debugElement.query(By.css('.validation-error')); expect(validationError).toBeFalsy(); })); + + it('should load more scripts when scrolled to the bottom', fakeAsync(() => { + spyOn(component, 'loadScripts'); + const event = { + target: { + scrollTop: 100, + clientHeight: 200, + scrollHeight: 300, + }, + }; + + component.onScroll(event); + tick(); + + expect(component.loadScripts).toHaveBeenCalled(); + })); + + it('should load more scripts when scrolled almost to the bottom', fakeAsync(() => { + spyOn(component, 'loadScripts'); + const event = { + target: { + scrollTop: 99, + clientHeight: 200, + scrollHeight: 300, + }, + }; + + component.onScroll(event); + tick(); + + expect(component.loadScripts).toHaveBeenCalled(); + })); + + it('should not load more scripts if already loading', fakeAsync(() => { + spyOn(component, 'loadScripts'); + component.isLoading$.next(true); + const event = { + target: { + scrollTop: 100, + clientHeight: 200, + scrollHeight: 300, + }, + }; + + component.onScroll(event); + tick(); + + expect(component.loadScripts).not.toHaveBeenCalled(); + })); + + it('should not load more scripts if it is the last page', fakeAsync(() => { + spyOn(component, 'loadScripts'); + (component as any)._isLastPage = true; + const event = { + target: { + scrollTop: 100, + clientHeight: 200, + scrollHeight: 300, + }, + }; + + component.onScroll(event); + tick(); + + expect(component.loadScripts).not.toHaveBeenCalled(); + })); + + it('should not load more scripts if not scrolled to the bottom', fakeAsync(() => { + spyOn(component, 'loadScripts'); + const event = { + target: { + scrollTop: 50, + clientHeight: 200, + scrollHeight: 300, + }, + }; + + component.onScroll(event); + tick(); + + expect(component.loadScripts).not.toHaveBeenCalled(); + })); }); diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.ts b/src/app/process-page/form/scripts-select/scripts-select.component.ts index 8cc31085a4c..c585f4d0a2a 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.ts +++ b/src/app/process-page/form/scripts-select/scripts-select.component.ts @@ -95,7 +95,9 @@ export class ScriptsSelectComponent implements OnInit, OnDestroy { * @param event The scroll event */ onScroll(event: any) { - if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) { + // offset to fix issues with zooming in or out in the browser + const offset = 5; + if (event.target.scrollTop + event.target.clientHeight + offset >= event.target.scrollHeight) { if (!this.isLoading$.value && !this._isLastPage) { this.scriptOptions.currentPage++; this.loadScripts(); diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 713b32fabe1..a6940eca843 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -112,6 +112,12 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ @Input() entityType: string; + /** + * Search endpoint to use for finding authorized collections. + * Defaults to 'findSubmitAuthorized', but can be overridden (e.g. to 'findAdminAuthorized') + */ + @Input() searchHref = 'findSubmitAuthorized'; + /** * Emit to notify whether search is complete */ @@ -220,7 +226,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { followLink('parentCommunity')); } else { searchListService$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, true, true, followLink('parentCommunity')); + .getAuthorizedCollection(query, findOptions, true, true, this.searchHref, followLink('parentCommunity')); } this.searchListCollection$ = searchListService$.pipe( getFirstCompletedRemoteData(), diff --git a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss index db6e7daecfd..0323e8cfae5 100644 --- a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss +++ b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss @@ -1,4 +1,6 @@ img { max-width: var(--ds-comcol-logo-max-width); max-height: var(--ds-comcol-logo-max-height); + object-fit: contain; + object-position: left center; } diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts index b46df8ff36f..6550cee8d00 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts @@ -4,6 +4,8 @@ import { VarDirective } from '../../../utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { ActionType } from '../../../../core/resource-policy/models/action-type.model'; import { SearchService } from '../../../../core/shared/search/search.service'; import { CollectionDataService } from '../../../../core/data/collection-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; @@ -26,8 +28,10 @@ describe('AuthorizedCollectionSelectorComponent', () => { id: 'authorized-collection' }); collectionService = jasmine.createSpyObj('collectionService', { - getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), - getAuthorizedCollectionByEntityType: createSuccessfulRemoteDataObject$(createPaginatedList([collection])) + getSubmitAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + getAdminAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + getEditAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + getAuthorizedCollectionByEntityType: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), }); notificationsService = jasmine.createSpyObj('notificationsService', ['error']); TestBed.configureTestingModule({ @@ -50,23 +54,52 @@ describe('AuthorizedCollectionSelectorComponent', () => { }); describe('search', () => { - describe('when has no entity type', () => { - it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { - component.search('', 1).subscribe((resultRD) => { - expect(collectionService.getAuthorizedCollection).toHaveBeenCalled(); - expect(resultRD.payload.page.length).toEqual(1); - expect(resultRD.payload.page[0].indexableObject).toEqual(collection); - done(); + describe('when action type is ADD', () => { + describe('when has no entity type', () => { + it('should call getSubmitAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { + component.action = ActionType.ADD; + fixture.detectChanges(); + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getSubmitAuthorizedCollection).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); + }); + }); + + describe('when has entity type', () => { + it('should call getAuthorizedCollectionByEntityType and return the authorized collection in a SearchResult', (done) => { + component.entityType = 'test'; + component.action = ActionType.ADD; + fixture.detectChanges(); + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getAuthorizedCollectionByEntityType).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); }); }); }); - describe('when has entity type', () => { - it('should call getAuthorizedCollectionByEntityType and return the authorized collection in a SearchResult', (done) => { - component.entityType = 'test'; + describe('when action type is WRITE', () => { + it('should call getEditAuthorizedCollection', (done) => { + component.action = ActionType.WRITE; fixture.detectChanges(); component.search('', 1).subscribe((resultRD) => { - expect(collectionService.getAuthorizedCollectionByEntityType).toHaveBeenCalled(); + expect(collectionService.getEditAuthorizedCollection).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); + }); + }); + + describe('when action is not provided', () => { + it('should call getAdminAuthorizedCollection', (done) => { + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getAdminAuthorizedCollection).toHaveBeenCalled(); expect(resultRD.payload.page.length).toEqual(1); expect(resultRD.payload.page[0].indexableObject).toEqual(collection); done(); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index cc1f9822d67..e03f0a2e71c 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -17,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { Collection } from '../../../../core/shared/collection.model'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; @Component({ selector: 'ds-authorized-collection-selector', @@ -32,6 +33,11 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent */ @Input() entityType: string; + /** + * The action type to determine which authorized collections to fetch, defaults to ADMIN + */ + @Input() action: ActionType = ActionType.ADMIN; + constructor( protected searchService: SearchService, protected collectionDataService: CollectionDataService, @@ -62,15 +68,24 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent elementsPerPage: this.defaultPagination.pageSize }; - if (this.entityType) { + if (this.action === ActionType.WRITE) { searchListService$ = this.collectionDataService - .getAuthorizedCollectionByEntityType( - query, - this.entityType, - findOptions); + .getEditAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); + } else if (this.action === ActionType.ADD) { + if (this.entityType) { + searchListService$ = this.collectionDataService + .getAuthorizedCollectionByEntityType( + query, + this.entityType, + findOptions); + } else { + searchListService$ = this.collectionDataService + .getSubmitAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); + } } else { + // By default, search for admin authorized collections searchListService$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); + .getAdminAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); } return searchListService$.pipe( getFirstCompletedRemoteData(), diff --git a/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts new file mode 100644 index 00000000000..498e6522cbb --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts @@ -0,0 +1,95 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; + +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { Community } from '../../../../core/shared/community.model'; +import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; +import { createPaginatedList } from '../../../testing/utils.test'; +import { VarDirective } from '../../../utils/var.directive'; +import { AuthorizedCommunitySelectorComponent } from './authorized-community-selector.component'; + +describe('AuthorizedCommunitySelectorComponent', () => { + let component: AuthorizedCommunitySelectorComponent; + let fixture: ComponentFixture; + + let communityService; + let community; + + let notificationsService: NotificationsService; + + beforeEach(waitForAsync(() => { + community = Object.assign(new Community(), { + id: 'authorized-community', + }); + communityService = jasmine.createSpyObj('communityService', { + getAddAuthorizedCommunity: createSuccessfulRemoteDataObject$(createPaginatedList([community])), + getEditAuthorizedCommunity: createSuccessfulRemoteDataObject$(createPaginatedList([community])), + getAdminAuthorizedCommunity: createSuccessfulRemoteDataObject$(createPaginatedList([community])), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + TestBed.configureTestingModule({ + declarations: [AuthorizedCommunitySelectorComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: CommunityDataService, useValue: communityService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorizedCommunitySelectorComponent); + component = fixture.componentInstance; + component.types = [DSpaceObjectType.COMMUNITY]; + fixture.detectChanges(); + }); + + describe('search', () => { + describe('when action type is ADD', () => { + it('should call getAddAuthorizedCommunity and return the authorized community in a SearchResult', (done) => { + component.action = ActionType.ADD; + fixture.detectChanges(); + component.search('', 1).subscribe((resultRD) => { + expect(communityService.getAddAuthorizedCommunity).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(community); + done(); + }); + }); + }); + describe('when action type is WRITE', () => { + it('should call getEditAuthorizedCommunity and return the authorized community in a SearchResult', (done) => { + component.action = ActionType.WRITE; + fixture.detectChanges(); + component.search('', 1).subscribe((resultRD) => { + expect(communityService.getEditAuthorizedCommunity).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(community); + done(); + }); + }); + }); + describe('when action type is not provided', () => { + it('should call getAdminAuthorizedCommunity and return the authorized community in a SearchResult', (done) => { + component.search('', 1).subscribe((resultRD) => { + expect(communityService.getAdminAuthorizedCommunity).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(community); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts new file mode 100644 index 00000000000..61d18bf96b7 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts @@ -0,0 +1,94 @@ + +import { Component, Input } from '@angular/core'; + + +import { + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Community } from '../../../../core/shared/community.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { hasValue } from '../../../empty.util'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; +import { SearchResult } from '../../../search/models/search-result.model'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { DSOSelectorComponent } from '../dso-selector.component'; + +@Component({ + selector: 'ds-authorized-community-selector', + styleUrls: ['../dso-selector.component.scss'], + templateUrl: '../dso-selector.component.html', +}) +/** + * Component rendering a list of communities to select from + */ +export class AuthorizedCommunitySelectorComponent extends DSOSelectorComponent { + + /** + * The action type to determine which authorized communities to fetch + */ + @Input() action: ActionType = ActionType.ADMIN; + + constructor( + protected searchService: SearchService, + protected communityDataService: CommunityDataService, + protected notifcationsService: NotificationsService, + protected translate: TranslateService, + protected dsoNameService: DSONameService, + ) { + super(searchService, notifcationsService, translate, dsoNameService); + } + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return this.currentDSOId; + } + + /** + * Perform a search for authorized communities with the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + * @param useCache Whether or not to use the cache + */ + search(query: string, page: number, useCache: boolean = true): Observable>>> { + let searchListService$: Observable>> = null; + const findOptions: FindListOptions = { + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize, + }; + + if (this.action === ActionType.WRITE) { + searchListService$ = this.communityDataService + .getEditAuthorizedCommunity(query, findOptions, useCache, false, followLink('parentCommunity')); + } else if (this.action === ActionType.ADD) { + searchListService$ = this.communityDataService + .getAddAuthorizedCommunity(query, findOptions, useCache, false, followLink('parentCommunity')); + } else { + // By default, search for admin authorized communities + searchListService$ = this.communityDataService + .getAdminAuthorizedCommunity(query, findOptions, useCache, false, followLink('parentCommunity')); + } + return searchListService$.pipe( + getFirstCompletedRemoteData(), + map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { + payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CommunitySearchResult(), { indexableObject: col }))) : null, + })), + ); + } +} diff --git a/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.spec.ts new file mode 100644 index 00000000000..fcb67a93742 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.spec.ts @@ -0,0 +1,66 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; +import { Item } from 'src/app/core/shared/item.model'; +import { SearchService } from 'src/app/core/shared/search/search.service'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; +import { createPaginatedList } from 'src/app/shared/testing/utils.test'; + +import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; +import { VarDirective } from '../../../utils/var.directive'; +import { AuthorizedItemSelectorComponent } from './authorized-item-selector.component'; + +describe('AuthorizedItemSelectorComponent', () => { + let component: AuthorizedItemSelectorComponent; + let fixture: ComponentFixture; + + let itemService; + let item; + + let notificationsService: NotificationsService; + + beforeEach(waitForAsync(() => { + item = Object.assign(new Item(), { + id: 'authorized-item', + }); + itemService = jasmine.createSpyObj('itemService', { + findEditAuthorized: createSuccessfulRemoteDataObject$(createPaginatedList([item])), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + TestBed.configureTestingModule({ + declarations: [AuthorizedItemSelectorComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: ItemDataService, useValue: itemService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorizedItemSelectorComponent); + component = fixture.componentInstance; + component.types = [DSpaceObjectType.ITEM]; + fixture.detectChanges(); + }); + + describe('search', () => { + it('should call findEditAuthorized and return the authorized item in a SearchResult', (done) => { + component.search('', 1).subscribe((resultRD) => { + expect(itemService.findEditAuthorized).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(item); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.ts new file mode 100644 index 00000000000..3367d931477 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-item-selector/authorized-item-selector.component.ts @@ -0,0 +1,84 @@ +import { + Component, + Input, +} from '@angular/core'; +import { + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; +import { Item } from 'src/app/core/shared/item.model'; +import { SearchService } from 'src/app/core/shared/search/search.service'; +import { hasValue } from 'src/app/shared/empty.util'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { ItemSearchResult } from 'src/app/shared/object-collection/shared/item-search-result.model'; +import { SearchResult } from 'src/app/shared/search/models/search-result.model'; +import { followLink } from 'src/app/shared/utils/follow-link-config.model'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { SelectorActionType } from '../../modal-wrappers/dso-selector-modal-wrapper.component'; +import { DSOSelectorComponent } from '../dso-selector.component'; + +@Component({ + selector: 'ds-authorized-item-selector', + styleUrls: ['../dso-selector.component.scss'], + templateUrl: '../dso-selector.component.html', +}) +/** + * Component rendering a list of item to select from for editing + */ +export class AuthorizedItemSelectorComponent extends DSOSelectorComponent { + + constructor( + protected searchService: SearchService, + protected itemDataService: ItemDataService, + protected notifcationsService: NotificationsService, + protected translate: TranslateService, + protected dsoNameService: DSONameService, + ) { + super(searchService, notifcationsService, translate, dsoNameService); + } + + @Input() action: SelectorActionType = SelectorActionType.EDIT; + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return this.currentDSOId; + } + + /** + * Perform a search for authorized collections with the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + * @param useCache Whether or not to use the cache + */ + search(query: string, page: number, useCache: boolean = true): Observable>>> { + let searchListService$: Observable>> = null; + const findOptions: FindListOptions = { + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize, + }; + + // By default, search for edit authorized items + searchListService$ = this.itemDataService + .findEditAuthorized(query, findOptions, useCache, false, followLink('owningCollection')); + + return searchListService$.pipe( + getFirstCompletedRemoteData(), + map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { + payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))) : null, + })), + ); + } +} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html new file mode 100644 index 00000000000..2bcc5455d88 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html @@ -0,0 +1,13 @@ +
+ + +
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 e0b7c1675b8..cc01c897645 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 '../../../../collection-page/collection-page-routing-paths'; import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; import { environment } from '../../../../../environments/environment'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a list of existing communities inside a modal * Used to choose a community from to create a new collection in @@ -17,12 +18,13 @@ import { environment } from '../../../../../environments/environment'; @Component({ selector: 'ds-create-collection-parent-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './create-collection-parent-selector.component.html', }) 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.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts index e44a7450e67..b78eb620684 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 @@ -17,6 +17,7 @@ import { environment } from '../../../../../environments/environment'; import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { Observable } from 'rxjs'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a button - for top communities - @@ -34,6 +35,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; @@ -42,6 +44,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 ed8a7b0780e..d05834fdcbe 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 @@ -6,6 +6,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; import { environment } from '../../../../../environments/environment'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a list of existing collections inside a modal @@ -22,6 +23,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.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts index fd54cd44ed2..7bd32636549 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 @@ -10,7 +10,7 @@ import { import { getCollectionEditRoute } from '../../../../collection-page/collection-page-routing-paths'; import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; import { environment } from '../../../../../environments/environment'; - +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a list of existing collections inside a modal * Used to choose a collection from to edit @@ -18,12 +18,14 @@ import { environment } from '../../../../../environments/environment'; @Component({ selector: 'ds-edit-collection-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-collection-selector.component.html', }) 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.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts index cf2f97c6d36..2cf826a233e 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 @@ -10,6 +10,7 @@ import { import { getCommunityEditRoute } from '../../../../community-page/community-page-routing-paths'; import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; import { environment } from '../../../../../environments/environment'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a list of existing communities inside a modal @@ -18,13 +19,15 @@ import { environment } from '../../../../../environments/environment'; @Component({ selector: 'ds-edit-community-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-community-selector.component.html', }) 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.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index c1ae5839081..b4624b27c4e 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 @@ -6,6 +6,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; import { getItemEditRoute } from '../../../../item-page/item-page-routing-paths'; import { Item } from '../../../../core/shared/item.model'; +import { ActionType } from 'src/app/core/resource-policy/models/action-type.model'; /** * Component to wrap a list of existing items inside a modal @@ -20,6 +21,7 @@ export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent 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 9e73b55f751..68e0d8f885f 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,4 +1,4 @@ -
@@ -19,8 +19,7 @@ -
+
@@ -78,7 +77,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.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 15ca2912e0b..7e09132fd3e 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 @@ -560,6 +560,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo * for this instance's base ID. */ ngOnDestroy(): void { + super.ngOnDestroy(); if (this._baseId) { const state = DsDynamicFormControlContainerComponent._idState.get(this._baseId); if (state) { 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 62d34a86254..8e3be79e56a 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/date-picker/date-picker.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss index 97698b2102e..c76d9fa95c3 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss @@ -4,4 +4,5 @@ legend { font-size: initial; + margin-bottom: 0; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index a6a4c451700..96f2300b085 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -22,10 +22,10 @@ import { mockDynamicFormValidationService } from '../../../../../testing/dynamic-form-mock-services'; -function createKeyUpEvent(key: number) { +function createKeyUpEvent(key: string) { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const event = { - keyCode: key, preventDefault: () => { + key: key, preventDefault: () => { }, stopPropagation: () => { } }; @@ -256,8 +256,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(); @@ -268,7 +268,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 862f3c14eb4..d910e5e6924 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 @@ -180,13 +180,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/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html index 0f4b436cfb6..f8eb28fa731 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.html @@ -117,3 +117,16 @@

+ + +

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 c13526f75dd..f684a1337d9 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.ts @@ -1,8 +1,8 @@ import { FlatTreeControl } from '@angular/cdk/tree'; import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; +import { Observable, Subscription, of } from 'rxjs'; import { map, tap, switchMap } from 'rxjs/operators'; -import { Observable, Subscription } from 'rxjs'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -116,6 +116,10 @@ export class VocabularyTreeviewComponent implements OnDestroy, OnInit, OnChanges readonly AlertType = AlertType; + public showNextPage$: Observable; + + public showPreviousPage$: Observable; + /** * Initialize instance variables * @@ -222,6 +226,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 @@ -289,6 +299,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; @@ -298,6 +319,28 @@ 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(selectedItems: string[]) { + const svc = this.vocabularyTreeviewService; + if (svc.currentPage < svc.totalPages) { + svc.searchByQueryAndPage(svc.queryInProgress, selectedItems, 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(selectedItems: string[]) { + const svc = this.vocabularyTreeviewService; + if (svc.currentPage > 1) { + svc.searchByQueryAndPage(svc.queryInProgress, selectedItems, svc.currentPage - 1); + } + } + /** * Check if search box contains any text */ @@ -328,6 +371,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 f524af4c0e2..e11ce4da0d0 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { map, merge, mergeMap, scan } from 'rxjs/operators'; +import { map, merge, mergeMap, scan, tap } from 'rxjs/operators'; import findIndex from 'lodash/findIndex'; import { @@ -17,10 +17,11 @@ import { isEmpty, isNotEmpty } from '../../empty.util'; import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteListPayload + getFirstSucceededRemoteListPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { RemoteData } from '../../../core/data/remote-data'; /** * A service that provides methods to deal with vocabulary tree @@ -79,6 +80,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 * @@ -186,10 +193,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; @@ -197,9 +222,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/html-content.service.spec.ts b/src/app/shared/html-content.service.spec.ts index dbc04dcf4e4..7c598616619 100644 --- a/src/app/shared/html-content.service.spec.ts +++ b/src/app/shared/html-content.service.spec.ts @@ -9,7 +9,7 @@ import { APP_CONFIG } from '../../config/app-config.interface'; class LocaleServiceStub { languageCode = 'en'; - getCurrentLanguageCode(): string { + getCurrentLanguageCodeSync(): string { return this.languageCode; } } diff --git a/src/app/shared/html-content.service.ts b/src/app/shared/html-content.service.ts index 664eaf71e22..ce941d869fb 100644 --- a/src/app/shared/html-content.service.ts +++ b/src/app/shared/html-content.service.ts @@ -97,7 +97,7 @@ export class HtmlContentService { async getHmtlContentByPathAndLocale(fileName: string) { let url = ''; // Get current language - let language = this.localeService.getCurrentLanguageCode(); + let language = this.localeService.getCurrentLanguageCodeSync(); // If language is default = `en` do not load static files from translated package e.g. `cs`. language = language === 'en' ? '' : language; diff --git a/src/app/shared/mocks/item.mock.ts b/src/app/shared/mocks/item.mock.ts index 77685cca9ac..c6f59ce4818 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/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts index 5faf02eac05..ed6ee822a1f 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts @@ -33,6 +33,11 @@ export class ItemDetailPreviewFieldComponent { */ @Input() metadata: string | string[]; + /** + * Escape HTML in the metadata value + */ + @Input() escapeMetadataHTML: boolean; + /** * The placeholder if there are no value to show */ @@ -50,6 +55,6 @@ export class ItemDetailPreviewFieldComponent { * @returns {string[]} the matching string values or an empty array. */ allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.item.metadata], keyOrKeys); + return Metadata.allValues(this.item.metadata, keyOrKeys, this.object.hitHighlights, undefined, this.escapeMetadataHTML); } } diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html index c2f5299d4bc..9fa82b534d6 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html @@ -40,6 +40,7 @@ [object]="object" [label]="('item.page.abstract' | translate)" [metadata]="'dc.description.abstract'" + [escapeMetadataHTML]="false" [separator]="separator" [placeholder]="('mydspace.results.no-abstract' | translate)"> , K ext * Gets all matching metadata string values 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 * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + return Metadata.allValues(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** * 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 * @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-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html index 7e2f093a32e..4e4e4586fe1 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html @@ -29,9 +29,9 @@

- +

- +

diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts index 083b8779b6a..bf18dcb41da 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts @@ -26,25 +26,27 @@ import { TruncatableService } from '../../../../truncatable/truncatable.service' import { TruncatePipe } from '../../../../utils/truncate.pipe'; import { DsLangPipe } from '../../../../utils/ds-lang.pipe'; import { ItemSearchResultGridElementComponent } from './item-search-result-grid-element.component'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); -mockItemWithMetadata.hitHighlights = {}; const mockItemWithAbstractOnly: ItemSearchResult = new ItemSearchResult(); mockItemWithAbstractOnly.hitHighlights = {}; const dcTitle = 'This is just another title'; +mockItemWithMetadata.hitHighlights = { + 'dc.title': [ + Object.assign(new MetadataValue(), { + value: dcTitle, + }), + ], +}; mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - hitHighlights: { - 'dc.title': [{ - value: dcTitle - }], - }, bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { language: 'en_US', - value: dcTitle - } + value: 'This is just another title', + }, ], 'dc.contributor.author': [ { diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts index 7d237e3f792..479cb692cd4 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts @@ -42,6 +42,6 @@ export class ItemSearchResultGridElementComponent extends SearchResultGridElemen ngOnInit(): void { super.ngOnInit(); this.itemPageRoute = getItemPageRoute(this.dso); - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso, true); } } diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index 4c3431bb55d..928b4e34778 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -47,20 +47,22 @@ export class SearchResultGridElementComponent, K exten * Gets all matching metadata string values 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 * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + return Metadata.allValues(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** * 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 * @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); } private isCollapsed(): Observable { diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html index 539ce77939a..14b7e05bd85 100644 --- a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html @@ -1,5 +1,5 @@
- + {{object.value}} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index b4fade662b8..5098be9e4cf 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -14,16 +14,16 @@

( + [innerHTML]="item.firstMetadataValue('dc.publisher', undefined, true) + ', '"> ) + [innerHTML]="item.firstMetadataValue('dc.date.issued', undefined, true) || ('mydspace.results.no-date' | translate)">) {{'mydspace.results.no-authors' | translate}} + *ngFor="let author of item.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], undefined, true); let last=last;"> ; @@ -33,8 +33,8 @@

- +

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 be94eb85aad..414476a18a9 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 @@ -59,7 +59,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 fb3518fe1cf..f8702e8ce60 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 1d65dd8b128..258a6072780 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 @@ -33,7 +33,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); } } @@ -41,12 +41,14 @@ 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[] { - let dsoMetadata: string[] = Metadata.allValues([this.dso.metadata], keyOrKeys); - let highlights: string[] = Metadata.allValues([this.object.hitHighlights], keyOrKeys); - let removedHighlights: string[] = highlights.map(str => str.replace(/<\/?em>/g, '')); + 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++) { let index = dsoMetadata.indexOf(removedHighlights[i]); if (index !== -1) { @@ -60,10 +62,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 2ec5cee7a6e..e31dddee290 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 @@ -113,7 +113,7 @@ export class SidebarSearchListElementComponent, K exte !parentRD.hasSucceeded) { return observableOf(accumulatedNames); } - const parentName = this.dsoNameService.getName(parentRD.payload); + const parentName = this.dsoNameService.getName(parentRD.payload, true); const newAccumulatedNames = hasValue(parentName) ? [parentName, ...accumulatedNames] : accumulatedNames; 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 c51ebf97231..c0023583376 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/resource-policies/entry/resource-policy-entry.component.html b/src/app/shared/resource-policies/entry/resource-policy-entry.component.html index 9ad019c8c68..b9caba98995 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 b5232459f55..afc61786d56 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 @@ -218,5 +218,16 @@ 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 83733c7011c..bef781ab606 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 @@ -17,6 +17,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ActivatedRoute, Router } from '@angular/router'; import { Group } from '../../../core/eperson/models/group.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; import { getGroupEditRoute } from '../../../access-control/access-control-routing-paths'; import { GroupDataService } from '../../../core/eperson/group-data.service'; @@ -76,6 +77,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.model.ts b/src/app/shared/resource-policies/form/resource-policy-form.model.ts index 71d223e7640..3929a618ab3 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 85d77ac6311..d567ac0c441 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/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 65c566c1e13..5bce0584779 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" queryParamsHandling="merge" - (click)="announceFilter()"> + (click)="announceFilter()" + rel="nofollow">