diff --git a/.browserslistrc b/.browserslistrc
deleted file mode 100644
index 427441dc93..0000000000
--- a/.browserslistrc
+++ /dev/null
@@ -1,17 +0,0 @@
-# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
-# For additional information regarding the format and rule options, please see:
-# https://github.com/browserslist/browserslist#queries
-
-# For the full list of supported browsers by the Angular framework, please see:
-# https://angular.io/guide/browser-support
-
-# You can see what browsers were selected by your queries by running:
-# npx browserslist
-
-last 1 Chrome version
-last 1 Firefox version
-last 2 Edge major versions
-last 2 Safari major versions
-last 2 iOS major versions
-Firefox ESR
-not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.
diff --git a/.editorconfig b/.editorconfig
index 15d4c87b14..590d1dea08 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
[*.ts]
quote_type = single
+
+[*.json5]
+ij_json_keep_blank_lines_in_code = 3
diff --git a/.eslintrc.json b/.eslintrc.json
index 6d5aa89db7..af1b97849b 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -6,7 +6,9 @@
"eslint-plugin-import",
"eslint-plugin-jsdoc",
"eslint-plugin-deprecation",
- "eslint-plugin-unused-imports"
+ "unused-imports",
+ "eslint-plugin-lodash",
+ "eslint-plugin-jsonc"
],
"overrides": [
{
@@ -202,7 +204,13 @@
"deprecation/deprecation": "warn",
"import/order": "off",
- "import/no-deprecated": "warn"
+ "import/no-deprecated": "warn",
+ "import/no-namespace": "error",
+ "unused-imports/no-unused-imports": "error",
+ "lodash/import-scope": [
+ "error",
+ "method"
+ ]
}
},
{
@@ -217,6 +225,42 @@
"@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off"
}
+ },
+ {
+ "files": [
+ "*.json5"
+ ],
+ "extends": [
+ "plugin:jsonc/recommended-with-jsonc"
+ ],
+ "rules": {
+ "no-irregular-whitespace": "error",
+ "no-trailing-spaces": "error",
+ "jsonc/comma-dangle": [
+ "error",
+ "always-multiline"
+ ],
+ "jsonc/indent": [
+ "error",
+ 2
+ ],
+ "jsonc/key-spacing": [
+ "error",
+ {
+ "beforeColon": false,
+ "afterColon": true,
+ "mode": "strict"
+ }
+ ],
+ "jsonc/no-dupe-keys": "off",
+ "jsonc/quotes": [
+ "error",
+ "double",
+ {
+ "avoidEscape": false
+ }
+ ]
+ }
}
]
}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index be15b0a507..e50105b879 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,7 +1,7 @@
## References
_Add references/links to any related issues or PRs. These may include:_
-* Fixes #[issue-number]
-* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this)
+* Fixes #`issue-number` (if this fixes an issue ticket)
+* Requires DSpace/DSpace#`pr-number` (if a REST API PR is required to test this)
## Description
Short summary of changes (1-2 sentences).
@@ -19,8 +19,10 @@ List of changes in this PR:
_This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_
- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
-- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint`
-- [ ] My PR doesn't introduce circular dependencies
+- [ ] My PR passes [ESLint](https://eslint.org/) validation using `yarn lint`
+- [ ] My PR doesn't introduce circular dependencies (verified via `yarn check-circ-deps`)
- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
-- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
+- [ ] If my PR includes new libraries/dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
+- [ ] If my PR includes new features or configurations, I've provided basic technical documentation in the PR itself.
+- [ ] If my PR fixes an issue ticket, I've [linked them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 04d426d091..219074780e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,34 +6,48 @@ name: Build
# Run this Build for all pushes / PRs to current branch
on: [push, pull_request]
+permissions:
+ contents: read # to fetch code (actions/checkout)
+
jobs:
tests:
runs-on: ubuntu-latest
env:
# The ci step will test the dspace-angular code against DSpace REST.
# Direct that step to utilize a DSpace REST service that has been started in docker.
- DSPACE_REST_HOST: localhost
+ # NOTE: These settings should be kept in sync with those in [src]/docker/docker-compose-ci.yml
+ DSPACE_REST_HOST: 127.0.0.1
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false
+ # 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
+ # Ensure all SSR caching is disabled in test environment
+ DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0
+ DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0
+ # Tell Cypress to run e2e tests using the same UI URL
+ CYPRESS_BASE_URL: http://127.0.0.1:4000
# When Chrome version is specified, we pin to a specific version of Chrome
# Comment this out to use the latest release
#CHROME_VERSION: "90.0.4430.212-1"
+ # Bump Node heap size (OOM in CI after upgrading to Angular 15)
+ NODE_OPTIONS: '--max-old-space-size=4096'
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
- node-version: [14.x, 16.x]
+ node-version: [16.x, 18.x]
# Do NOT exit immediately if one matrix job fails
fail-fast: false
# These are the actual CI steps to perform per job
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
# https://github.com/actions/setup-node
- name: Install Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
@@ -56,9 +70,9 @@ jobs:
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
- name: Get Yarn cache directory
id: yarn-cache-dir-path
- run: echo "::set-output name=dir::$(yarn cache dir)"
+ run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache Yarn dependencies
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
# Cache entire Yarn cache directory (see previous step)
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -81,12 +95,16 @@ jobs:
- name: Run specs (unit tests)
run: yarn run test:headless
+ # Upload code coverage report to artifact (for one version of Node only),
+ # so that it can be shared with the 'codecov' job (see below)
# NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286
- # Upload coverage reports to Codecov (for one version of Node only)
- # https://github.com/codecov/codecov-action
- - name: Upload coverage to Codecov.io
- uses: codecov/codecov-action@v2
- if: matrix.node-version == '16.x'
+ - name: Upload code coverage report to Artifact
+ uses: actions/upload-artifact@v3
+ if: matrix.node-version == '18.x'
+ with:
+ name: dspace-angular coverage report
+ path: 'coverage/dspace-angular/lcov.info'
+ retention-days: 14
# Using docker-compose start backend using CI configuration
# and load assetstore from a cached copy
@@ -100,23 +118,22 @@ 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@v2
+ uses: cypress-io/github-action@v5
with:
- # Run tests in Chrome, headless mode
+ # Run tests in Chrome, headless mode (default)
browser: chrome
- headless: true
# Start app before running tests (will be stopped automatically after tests finish)
start: yarn run serve:ssr
# Wait for backend & frontend to be available
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
- wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000
+ wait-on: http://127.0.0.1:8080/server/api/core/sites, http://127.0.0.1:4000
# Wait for 2 mins max for everything to respond
wait-on-timeout: 120
# 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@v2
+ uses: actions/upload-artifact@v3
if: always()
with:
name: e2e-test-videos
@@ -125,7 +142,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@v2
+ uses: actions/upload-artifact@v3
if: failure()
with:
name: e2e-test-screenshots
@@ -144,7 +161,7 @@ jobs:
run: |
nohup yarn run serve:ssr &
printf 'Waiting for app to start'
- until curl --output /dev/null --silent --head --fail http://localhost:4000/home; do
+ until curl --output /dev/null --silent --head --fail http://127.0.0.1:4000/home; do
printf '.'
sleep 2
done
@@ -155,7 +172,7 @@ jobs:
# This step also prints entire HTML of homepage for easier debugging if grep fails.
- name: Verify SSR (server-side rendering)
run: |
- result=$(wget -O- -q http://localhost:4000/home)
+ result=$(wget -O- -q http://127.0.0.1:4000/home)
echo "$result"
echo "$result" | grep -oE "]*>" | grep DSpace
@@ -164,3 +181,32 @@ jobs:
- name: Shutdown Docker containers
run: docker-compose -f ./docker/docker-compose-ci.yml down
+
+ # Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
+ # job above. This is necessary because Codecov uploads seem to randomly fail at times.
+ # See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954
+ codecov:
+ # Must run after 'tests' job above
+ needs: tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ # Download artifacts from previous 'tests' job
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@v3
+
+ # Now attempt upload to Codecov using its action.
+ # NOTE: We use a retry action to retry the Codecov upload if it fails the first time.
+ #
+ # Retry action: https://github.com/marketplace/actions/retry-action
+ # Codecov action: https://github.com/codecov/codecov-action
+ - name: Upload coverage to Codecov.io
+ uses: Wandalen/wretry.action@v1.0.36
+ with:
+ action: codecov/codecov-action@v3
+ # Try upload 5 times max
+ attempt_limit: 5
+ # Run again in 30 seconds
+ attempt_delay: 30000
diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml
new file mode 100644
index 0000000000..35a2e2d24a
--- /dev/null
+++ b/.github/workflows/codescan.yml
@@ -0,0 +1,49 @@
+# DSpace CodeQL code scanning configuration for GitHub
+# https://docs.github.com/en/code-security/code-scanning
+#
+# NOTE: Code scanning must be run separate from our default build.yml
+# because CodeQL requires a fresh build with all tests *disabled*.
+name: "Code Scanning"
+
+# Run this code scan for all pushes / PRs to main branch. Also run once a week.
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ # Don't run if PR is only updating static documentation
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.txt'
+ schedule:
+ - cron: "37 0 * * 1"
+
+jobs:
+ analyze:
+ name: Analyze Code
+ runs-on: ubuntu-latest
+ # Limit permissions of this GitHub action. Can only write to security-events
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ # https://github.com/actions/checkout
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ # Initializes the CodeQL tools for scanning.
+ # https://github.com/github/codeql-action
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: javascript
+
+ # Autobuild attempts to build any compiled languages
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+
+ # Perform GitHub Code Scanning.
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
\ No newline at end of file
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 64303ca8bb..9a2c838d83 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -12,6 +12,9 @@ on:
- 'dspace-**'
pull_request:
+permissions:
+ contents: read # to fetch code (actions/checkout)
+
jobs:
docker:
# Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
@@ -39,11 +42,11 @@ jobs:
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx
- uses: docker/setup-buildx-action@v1
+ uses: docker/setup-buildx-action@v2
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU emulation to build for multiple architectures
@@ -53,7 +56,7 @@ jobs:
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request'
- uses: docker/login-action@v1
+ uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
@@ -65,7 +68,7 @@ jobs:
# Get Metadata for docker_build step below
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image
id: meta_build
- uses: docker/metadata-action@v3
+ uses: docker/metadata-action@v4
with:
images: dspace/dspace-angular
tags: ${{ env.IMAGE_TAGS }}
@@ -74,7 +77,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push 'dspace-angular' image
id: docker_build
- uses: docker/build-push-action@v2
+ uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
@@ -85,3 +88,33 @@ jobs:
# Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build.outputs.tags }}
labels: ${{ steps.meta_build.outputs.labels }}
+
+ #####################################################
+ # Build/Push the 'dspace/dspace-angular' image ('-dist' tag)
+ #####################################################
+ # https://github.com/docker/metadata-action
+ # Get Metadata for docker_build_dist step below
+ - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image
+ id: meta_build_dist
+ uses: docker/metadata-action@v4
+ with:
+ images: dspace/dspace-angular
+ tags: ${{ env.IMAGE_TAGS }}
+ # As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
+ # tagging logic as the primary 'dspace/dspace-angular' image above.
+ flavor: ${{ env.TAGS_FLAVOR }}
+ suffix=-dist
+
+ - name: Build and push 'dspace-angular-dist' image
+ id: docker_build_dist
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ file: ./Dockerfile.dist
+ platforms: ${{ env.PLATFORMS }}
+ # For pull requests, we run the Docker build (to ensure no PR changes break the build),
+ # but we ONLY do an image push to DockerHub if it's NOT a PR
+ push: ${{ github.event_name != 'pull_request' }}
+ # Use tags / labels provided by 'docker/metadata-action' above
+ tags: ${{ steps.meta_build_dist.outputs.tags }}
+ labels: ${{ steps.meta_build_dist.outputs.labels }}
diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml
index 6b9a273ab6..b4436dca3a 100644
--- a/.github/workflows/issue_opened.yml
+++ b/.github/workflows/issue_opened.yml
@@ -5,25 +5,22 @@ on:
issues:
types: [opened]
+permissions: {}
jobs:
automation:
runs-on: ubuntu-latest
steps:
# Add the new issue to a project board, if it needs triage
- # See https://github.com/marketplace/actions/create-project-card-action
- - name: Add issue to project board
+ # See https://github.com/actions/add-to-project
+ - name: Add issue to triage board
# Only add to project board if issue is flagged as "needs triage" or has no labels
# NOTE: By default we flag new issues as "needs triage" in our issue template
if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '')
- uses: technote-space/create-project-card-action@v1
+ uses: actions/add-to-project@v0.5.0
# Note, the authentication token below is an ORG level Secret.
- # It must be created/recreated manually via a personal access token with "public_repo" and "admin:org" permissions
+ # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token
# This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific)
with:
- GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }}
- PROJECT: DSpace Backlog
- COLUMN: Triage
- CHECK_ORG_PROJECT: true
- # Ignore errors
- continue-on-error: true
+ github-token: ${{ secrets.TRIAGE_PROJECT_TOKEN }}
+ project-url: https://github.com/orgs/DSpace/projects/24
diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml
index dcbab18f1b..c1396b6f45 100644
--- a/.github/workflows/label_merge_conflicts.yml
+++ b/.github/workflows/label_merge_conflicts.yml
@@ -5,21 +5,32 @@ name: Check for merge conflicts
# NOTE: This means merge conflicts are only checked for when a PR is merged to main.
on:
push:
- branches:
- - main
+ branches: [ main ]
+ # So that the `conflict_label_name` is removed if conflicts are resolved,
+ # we allow this to run for `pull_request_target` so that github secrets are available.
+ pull_request_target:
+ types: [ synchronize ]
+
+permissions: {}
jobs:
triage:
+ # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
+ if: github.repository == 'dspace/dspace-angular'
runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
steps:
- # See: https://github.com/mschilde/auto-label-merge-conflicts/
+ # See: https://github.com/prince-chrismc/label-merge-conflicts-action
- name: Auto-label PRs with merge conflicts
- uses: mschilde/auto-label-merge-conflicts@v2.0
+ uses: prince-chrismc/label-merge-conflicts-action@v3
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
# Note, the authentication token is created automatically
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
with:
- CONFLICT_LABEL_NAME: 'merge conflict'
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Ignore errors
- continue-on-error: true
+ conflict_label_name: 'merge conflict'
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ conflict_comment: |
+ Hi @${author},
+ Conflicts have been detected against the base branch.
+ Please [resolve these conflicts](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/about-merge-conflicts) as soon as you can. Thanks!
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index bdd0d4e589..7d065aca06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,5 @@ package-lock.json
/nbproject/
junit.xml
+
+/src/mirador-viewer/config.local.js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000..4e732302f4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,46 @@
+# How to Contribute
+
+DSpace is a community built and supported project. We do not have a centralized development or support team, but have a dedicated group of volunteers who help us improve the software, documentation, resources, etc.
+
+* [Contribute new code via a Pull Request](#contribute-new-code-via-a-pull-request)
+* [Contribute documentation](#contribute-documentation)
+* [Help others on mailing lists or Slack](#help-others-on-mailing-lists-or-slack)
+* [Join a working or interest group](#join-a-working-or-interest-group)
+
+## Contribute new code via a Pull Request
+
+We accept [GitHub Pull Requests (PRs)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) at any time from anyone.
+Contributors to each release are recognized in our [Release Notes](https://wiki.lyrasis.org/display/DSDOC7x/Release+Notes).
+
+Code Contribution Checklist
+- [ ] PRs _should_ be smaller in size (ideally less than 1,000 lines of code, not including comments & tests)
+- [ ] PRs **must** pass [ESLint](https://eslint.org/) validation using `yarn lint`
+- [ ] PRs **must** not introduce circular dependencies (verified via `yarn check-circ-deps`)
+- [ ] PRs **must** include [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. Large or complex private methods should also have TypeDoc.
+- [ ] PRs **must** pass all automated pecs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
+- [ ] If a PR includes new libraries/dependencies (in `package.json`), then their software licenses **must** align with the [DSpace BSD License](https://github.com/DSpace/dspace-angular/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
+- [ ] Basic technical documentation _should_ be provided for any new features or configuration, either in the PR itself or in the DSpace Wiki documentation.
+- [ ] If a PR fixes an issue ticket, please [link them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
+
+Additional details on the code contribution process can be found in our [Code Contribution Guidelines](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines)
+
+## Contribute documentation
+
+DSpace Documentation is a collaborative effort in a shared Wiki. The latest documentation is at https://wiki.lyrasis.org/display/DSDOC7x
+
+If you find areas of the DSpace Documentation which you wish to improve, please request a Wiki account by emailing wikihelp@lyrasis.org.
+Once you have an account setup, contact @tdonohue (via [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) or email) for access to edit our Documentation.
+
+## Help others on mailing lists or Slack
+
+DSpace has our own [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) community and [Mailing Lists](https://wiki.lyrasis.org/display/DSPACE/Mailing+Lists) where discussions take place and questions are answered.
+Anyone is welcome to join and help others. We just ask you to follow our [Code of Conduct](https://www.lyrasis.org/about/Pages/Code-of-Conduct.aspx) (adopted via LYRASIS).
+
+## Join a working or interest group
+
+Most of the work in building/improving DSpace comes via [Working Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Working+Groups) or [Interest Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Interest+Groups).
+
+All working/interest groups are open to anyone to join and participate. A few key groups to be aware of include:
+
+* [DSpace 7 Working Group](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+Working+Group) - This is the main (mostly volunteer) development team. We meet weekly to review our current development [project board](https://github.com/orgs/DSpace/projects), assigning tickets and/or PRs.
+* [DSpace Community Advisory Team (DCAT)](https://wiki.lyrasis.org/display/cmtygp/DSpace+Community+Advisory+Team) - This is an interest group for repository managers/administrators. We meet monthly to discuss DSpace, share tips & provide feedback back to developers.
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index a7c1640d0b..8fac7495e1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,12 @@
# This image will be published as dspace/dspace-angular
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
-FROM node:14-alpine
+FROM node:18-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
@@ -10,8 +15,14 @@ EXPOSE 4000
# See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000
+# 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 IP. See https://github.com/DSpace/dspace-angular/issues/1485
+# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
+ENV NODE_ENV development
CMD yarn serve --host 0.0.0.0
diff --git a/Dockerfile.dist b/Dockerfile.dist
new file mode 100644
index 0000000000..2a6a66fc06
--- /dev/null
+++ b/Dockerfile.dist
@@ -0,0 +1,31 @@
+# This image will be published as dspace/dspace-angular:$DSPACE_VERSION-dist
+# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
+
+# Test build:
+# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
+
+FROM node:18-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 package.json yarn.lock ./
+RUN yarn install --network-timeout 300000
+
+ADD . /app/
+RUN yarn build:prod
+
+FROM node:18-alpine
+RUN npm install --global pm2
+
+COPY --chown=node:node --from=build /app/dist /app/dist
+COPY --chown=node:node config /app/config
+COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
+
+WORKDIR /app
+USER node
+ENV NODE_ENV production
+EXPOSE 4000
+CMD pm2-runtime start dspace-ui.json --json
diff --git a/README.md b/README.md
index 837cb48004..689c64a292 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start
-----------
-**Ensure you're running [Node](https://nodejs.org) `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`**
+**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`**
```bash
# clone the repo
@@ -90,7 +90,7 @@ Requirements
------------
- [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com)
-- Ensure you're running node `v14.x` or `v16.x` and yarn == `v1.x`
+- Ensure you're running node `v16.x` or `v18.x` and yarn == `v1.x`
If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS.
@@ -351,7 +351,7 @@ Documentation
Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/
-Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase.
+Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of this codebase.
### Building code documentation
@@ -379,10 +379,10 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've
- [Sublime Text](http://www.sublimetext.com/3)
- [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation)
-Collaborating
+Contributing
-------------
-See [the guide on the wiki](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+-+Angular+UI+Development#DSpace7-AngularUIDevelopment-Howtocontribute)
+See [Contributing documentation](CONTRIBUTING.md)
File Structure
--------------
@@ -413,8 +413,7 @@ dspace-angular
│ ├── merge-i18n-files.ts *
│ ├── serve.ts *
│ ├── sync-i18n-files.ts *
-│ ├── test-rest.ts *
-│ └── webpack.js *
+│ └── test-rest.ts *
├── src * The source of the application
│ ├── app * The source code of the application, subdivided by module/page.
│ ├── assets * Folder for static resources
diff --git a/angular.json b/angular.json
index 2ece0c5e7d..5e597d4d30 100644
--- a/angular.json
+++ b/angular.json
@@ -25,12 +25,10 @@
}
},
"allowedCommonJsDependencies": [
- "angular2-text-mask",
"cerialize",
"core-js",
"lodash",
"jwt-decode",
- "url-parse",
"uuid",
"webfontloader",
"zone.js"
@@ -268,16 +266,26 @@
"options": {
"lintFilePatterns": [
"src/**/*.ts",
- "src/**/*.html"
+ "src/**/*.html",
+ "src/**/*.json5"
]
}
}
}
}
},
- "defaultProject": "dspace-angular",
"cli": {
"analytics": false,
- "defaultCollection": "@angular-eslint/schematics"
+ "schematicCollections": [
+ "@angular-eslint/schematics"
+ ]
+ },
+ "schematics": {
+ "@angular-eslint/schematics:application": {
+ "setParserOptionsProject": true
+ },
+ "@angular-eslint/schematics:library": {
+ "setParserOptionsProject": true
+ }
}
}
diff --git a/config/config.example.yml b/config/config.example.yml
index 27400f0041..ea38303fa3 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -32,12 +32,60 @@ cache:
# NOTE: how long should objects be cached for by default
msToLive:
default: 900000 # 15 minutes
- control: max-age=60 # revalidate browser
+ # Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files)
+ # Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these
+ # files for one week, after which they will be "stale" and need to be redownloaded.
+ # 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
autoSync:
defaultTime: 0
maxBufferSize: 100
timePerMethod:
PATCH: 3 # time in seconds
+ # In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages.
+ # Pages are automatically added/dropped from these caches based on how recently they have been used.
+ # Restarting the app clears all page caches.
+ # NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB).
+ # Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive).
+ serverSide:
+ # Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues.
+ debug: false
+ # When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots.
+ # (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.)
+ botCache:
+ # Maximum number of pages to cache for known bots. Set to zero (0) to disable server side caching for bots.
+ # Default is 1000, which means the 1000 most recently accessed public pages will be cached.
+ # As all pages are cached in server memory, increasing this value will increase memory needs.
+ # Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory.
+ max: 1000
+ # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
+ # copy is automatically refreshed on the next request.
+ # NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site.
+ # For example, setting this to one week may mean that search engine bots may not find all new content for one week.
+ timeToLive: 86400000 # 1 day
+ # When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
+ # behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
+ # When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
+ # This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
+ allowStale: true
+ # When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache.
+ # This allows anonymous users to interact more quickly with the site, but also means they may see slightly
+ # outdated content (based on timeToLive)
+ anonymousCache:
+ # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled.
+ # As all pages are cached in server memory, increasing this value will increase memory needs.
+ # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory.
+ max: 0
+ # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
+ # copy is automatically refreshed on the next request.
+ # NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content.
+ timeToLive: 10000 # 10 seconds
+ # When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
+ # behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
+ # When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
+ # This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
+ allowStale: true
# Authentication settings
auth:
@@ -55,6 +103,8 @@ auth:
# Form settings
form:
+ # Sets the spellcheck textarea attribute value
+ spellCheck: true
# NOTE: Map server-side validators to comparative Angular form validators
validatorMap:
required: required
@@ -119,6 +169,9 @@ languages:
- code: en
label: English
active: true
+ - code: ca
+ label: Català
+ active: true
- code: cs
label: Čeština
active: true
@@ -134,6 +187,9 @@ languages:
- code: gd
label: Gàidhlig
active: true
+ - code: it
+ label: Italiano
+ active: true
- code: lv
label: Latviešu
active: true
@@ -143,6 +199,9 @@ languages:
- code: nl
label: Nederlands
active: true
+ - code: pl
+ label: Polski
+ active: true
- code: pt-PT
label: Português
active: true
@@ -158,6 +217,9 @@ languages:
- code: tr
label: Türkçe
active: true
+ - code: vi
+ label: Tiếng Việt
+ active: true
- code: kk
label: Қазақ
active: true
@@ -170,6 +232,10 @@ languages:
- code: el
label: Ελληνικά
active: true
+ - code: uk
+ label: Yкраї́нська
+ active: true
+
# Browse-By Pages
browseBy:
@@ -207,6 +273,11 @@ item:
undoTimeout: 10000 # 10 seconds
# Show the item access status label in items lists
showAccessStatuses: false
+ bitstream:
+ # Number of entries in the bitstream list in the item view page.
+ # Rounded to the nearest size in the list of selectable sizes on the
+ # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
+ pageSize: 5
# Collection Page Config
collection:
@@ -295,4 +366,17 @@ info:
# display in supported metadata fields. By default, only dc.description.abstract is supported.
markdown:
enabled: false
- mathjax: false
\ No newline at end of file
+ mathjax: false
+
+# Which vocabularies should be used for which search filters
+# and whether to show the filter in the search sidebar
+# Take a look at the filter-vocabulary-config.ts file for documentation on how the options are obtained
+vocabularies:
+ - filter: 'subject'
+ vocabulary: 'srsc'
+ enabled: true
+
+# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
+comcolSelectionSort:
+ sortField: 'dc.title'
+ sortDirection: 'ASC'
\ No newline at end of file
diff --git a/cypress.config.ts b/cypress.config.ts
new file mode 100644
index 0000000000..91eeb9838b
--- /dev/null
+++ b/cypress.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+ videosFolder: 'cypress/videos',
+ screenshotsFolder: 'cypress/screenshots',
+ fixturesFolder: 'cypress/fixtures',
+ retries: {
+ runMode: 2,
+ openMode: 0,
+ },
+ env: {
+ // Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts)
+ // May be overridden in our cypress.json config file using specified environment variables.
+ // Default values listed here are all valid for the Demo Entities Data set available at
+ // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
+ // (This is the data set used in our CI environment)
+
+ // Admin account used for administrative tests
+ DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com',
+ DSPACE_TEST_ADMIN_PASSWORD: 'dspace',
+ // Community/collection/publication used for view/edit tests
+ DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4',
+ DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200',
+ DSPACE_TEST_ENTITY_PUBLICATION: 'e98b0f27-5c19-49a0-960d-eb6ad5287067',
+ // Search term (should return results) used in search tests
+ DSPACE_TEST_SEARCH_TERM: 'test',
+ // Collection used for submission tests
+ DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
+ DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
+ // Account used to test basic submission process
+ DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
+ DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',
+ },
+ e2e: {
+ // Setup our plugins for e2e tests
+ setupNodeEvents(on, config) {
+ return require('./cypress/plugins/index.ts')(on, config);
+ },
+ // This is the base URL that Cypress will run all tests against
+ // It can be overridden via the CYPRESS_BASE_URL environment variable
+ // (By default we set this to a value which should work in most development environments)
+ baseUrl: 'http://localhost:4000',
+ },
+});
diff --git a/cypress.json b/cypress.json
deleted file mode 100644
index 80358eb6dd..0000000000
--- a/cypress.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "integrationFolder": "cypress/integration",
- "supportFile": "cypress/support/index.ts",
- "videosFolder": "cypress/videos",
- "screenshotsFolder": "cypress/screenshots",
- "pluginsFile": "cypress/plugins/index.ts",
- "fixturesFolder": "cypress/fixtures",
- "baseUrl": "http://localhost:4000",
- "retries": {
- "runMode": 2,
- "openMode": 0
- },
- "env": {
- "DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com",
- "DSPACE_TEST_ADMIN_PASSWORD": "dspace",
- "DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4",
- "DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200",
- "DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067",
- "DSPACE_TEST_SEARCH_TERM": "test",
- "DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection",
- "DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144",
- "DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com",
- "DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace"
- }
-}
\ No newline at end of file
diff --git a/cypress/integration/breadcrumbs.spec.ts b/cypress/e2e/breadcrumbs.cy.ts
similarity index 73%
rename from cypress/integration/breadcrumbs.spec.ts
rename to cypress/e2e/breadcrumbs.cy.ts
index 62b9a8ad1d..ea6acdafcd 100644
--- a/cypress/integration/breadcrumbs.spec.ts
+++ b/cypress/e2e/breadcrumbs.cy.ts
@@ -1,10 +1,10 @@
-import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
+import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Breadcrumbs', () => {
it('should pass accessibility tests', () => {
// Visit an Item, as those have more breadcrumbs
- cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
+ cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
// Wait for breadcrumbs to be visible
cy.get('ds-breadcrumbs').should('be.visible');
diff --git a/cypress/integration/browse-by-author.spec.ts b/cypress/e2e/browse-by-author.cy.ts
similarity index 100%
rename from cypress/integration/browse-by-author.spec.ts
rename to cypress/e2e/browse-by-author.cy.ts
diff --git a/cypress/integration/browse-by-dateissued.spec.ts b/cypress/e2e/browse-by-dateissued.cy.ts
similarity index 100%
rename from cypress/integration/browse-by-dateissued.spec.ts
rename to cypress/e2e/browse-by-dateissued.cy.ts
diff --git a/cypress/integration/browse-by-subject.spec.ts b/cypress/e2e/browse-by-subject.cy.ts
similarity index 100%
rename from cypress/integration/browse-by-subject.spec.ts
rename to cypress/e2e/browse-by-subject.cy.ts
diff --git a/cypress/integration/browse-by-title.spec.ts b/cypress/e2e/browse-by-title.cy.ts
similarity index 100%
rename from cypress/integration/browse-by-title.spec.ts
rename to cypress/e2e/browse-by-title.cy.ts
diff --git a/cypress/integration/collection-page.spec.ts b/cypress/e2e/collection-page.cy.ts
similarity index 64%
rename from cypress/integration/collection-page.spec.ts
rename to cypress/e2e/collection-page.cy.ts
index a0140d8faf..a034b4361d 100644
--- a/cypress/integration/collection-page.spec.ts
+++ b/cypress/e2e/collection-page.cy.ts
@@ -1,13 +1,13 @@
-import { TEST_COLLECTION } from 'cypress/support';
+import { TEST_COLLECTION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Collection Page', () => {
it('should pass accessibility tests', () => {
- cy.visit('/collections/' + TEST_COLLECTION);
+ cy.visit('/collections/'.concat(TEST_COLLECTION));
// tag must be loaded
- cy.get('ds-collection-page').should('exist');
+ cy.get('ds-collection-page').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-collection-page');
diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts
new file mode 100644
index 0000000000..6df4e9a454
--- /dev/null
+++ b/cypress/e2e/collection-statistics.cy.ts
@@ -0,0 +1,37 @@
+import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e';
+import { testA11y } from 'cypress/support/utils';
+
+describe('Collection Statistics Page', () => {
+ const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION);
+
+ it('should load if you click on "Statistics" from a Collection page', () => {
+ cy.visit('/collections/'.concat(TEST_COLLECTION));
+ cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
+ cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
+ });
+
+ it('should contain a "Total visits" section', () => {
+ cy.visit(COLLECTIONSTATISTICSPAGE);
+ cy.get('table[data-test="TotalVisits"]').should('be.visible');
+ });
+
+ it('should contain a "Total visits per month" section', () => {
+ cy.visit(COLLECTIONSTATISTICSPAGE);
+ // Check just for existence because this table is empty in CI environment as it's historical data
+ cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist');
+ });
+
+ it('should pass accessibility tests', () => {
+ cy.visit(COLLECTIONSTATISTICSPAGE);
+
+ // tag must be loaded
+ cy.get('ds-collection-statistics-page').should('be.visible');
+
+ // Verify / wait until "Total Visits" table's label is non-empty
+ // (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
+ cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
+
+ // Analyze for accessibility issues
+ testA11y('ds-collection-statistics-page');
+ });
+});
diff --git a/cypress/integration/community-list.spec.ts b/cypress/e2e/community-list.cy.ts
similarity index 72%
rename from cypress/integration/community-list.spec.ts
rename to cypress/e2e/community-list.cy.ts
index a7ba72b74a..7b60b59dbc 100644
--- a/cypress/integration/community-list.spec.ts
+++ b/cypress/e2e/community-list.cy.ts
@@ -7,10 +7,10 @@ describe('Community List Page', () => {
cy.visit('/community-list');
// tag must be loaded
- cy.get('ds-community-list-page').should('exist');
+ cy.get('ds-community-list-page').should('be.visible');
- // Open first Community (to show Collections)...that way we scan sub-elements as well
- cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click();
+ // Open every expand button on page, so that we can scan sub-elements as well
+ cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze for accessibility issues
// Disable heading-order checks until it is fixed
diff --git a/cypress/integration/community-page.spec.ts b/cypress/e2e/community-page.cy.ts
similarity index 64%
rename from cypress/integration/community-page.spec.ts
rename to cypress/e2e/community-page.cy.ts
index 79e21431ad..6c628e21ce 100644
--- a/cypress/integration/community-page.spec.ts
+++ b/cypress/e2e/community-page.cy.ts
@@ -1,13 +1,13 @@
-import { TEST_COMMUNITY } from 'cypress/support';
+import { TEST_COMMUNITY } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Community Page', () => {
it('should pass accessibility tests', () => {
- cy.visit('/communities/' + TEST_COMMUNITY);
+ cy.visit('/communities/'.concat(TEST_COMMUNITY));
// tag must be loaded
- cy.get('ds-community-page').should('exist');
+ cy.get('ds-community-page').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-community-page',);
diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts
new file mode 100644
index 0000000000..710450e797
--- /dev/null
+++ b/cypress/e2e/community-statistics.cy.ts
@@ -0,0 +1,37 @@
+import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e';
+import { testA11y } from 'cypress/support/utils';
+
+describe('Community Statistics Page', () => {
+ const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY);
+
+ it('should load if you click on "Statistics" from a Community page', () => {
+ cy.visit('/communities/'.concat(TEST_COMMUNITY));
+ cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
+ cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
+ });
+
+ it('should contain a "Total visits" section', () => {
+ cy.visit(COMMUNITYSTATISTICSPAGE);
+ cy.get('table[data-test="TotalVisits"]').should('be.visible');
+ });
+
+ it('should contain a "Total visits per month" section', () => {
+ cy.visit(COMMUNITYSTATISTICSPAGE);
+ // Check just for existence because this table is empty in CI environment as it's historical data
+ cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist');
+ });
+
+ it('should pass accessibility tests', () => {
+ cy.visit(COMMUNITYSTATISTICSPAGE);
+
+ // tag must be loaded
+ cy.get('ds-community-statistics-page').should('be.visible');
+
+ // Verify / wait until "Total Visits" table's label is non-empty
+ // (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
+ cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
+
+ // Analyze for accessibility issues
+ testA11y('ds-community-statistics-page');
+ });
+});
diff --git a/cypress/integration/footer.spec.ts b/cypress/e2e/footer.cy.ts
similarity index 100%
rename from cypress/integration/footer.spec.ts
rename to cypress/e2e/footer.cy.ts
diff --git a/cypress/integration/header.spec.ts b/cypress/e2e/header.cy.ts
similarity index 100%
rename from cypress/integration/header.spec.ts
rename to cypress/e2e/header.cy.ts
diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts
new file mode 100644
index 0000000000..2a1ab9785a
--- /dev/null
+++ b/cypress/e2e/homepage-statistics.cy.ts
@@ -0,0 +1,31 @@
+import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
+import { testA11y } from 'cypress/support/utils';
+import '../support/commands';
+
+describe('Site Statistics Page', () => {
+ it('should load if you click on "Statistics" from homepage', () => {
+ cy.visit('/');
+ cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
+ cy.location('pathname').should('eq', '/statistics');
+ });
+
+ it('should pass accessibility tests', () => {
+ // generate 2 view events on an Item's page
+ cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
+ cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
+
+ cy.visit('/statistics');
+
+ // tag must be visable
+ cy.get('ds-site-statistics-page').should('be.visible');
+
+ // Verify / wait until "Total Visits" table's *last* label is non-empty
+ // (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
+ cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT);
+ // Wait an extra 500ms, just so all entries in Total Visits have loaded.
+ cy.wait(500);
+
+ // Analyze for accessibility issues
+ testA11y('ds-site-statistics-page');
+ });
+});
diff --git a/cypress/integration/homepage.spec.ts b/cypress/e2e/homepage.cy.ts
similarity index 85%
rename from cypress/integration/homepage.spec.ts
rename to cypress/e2e/homepage.cy.ts
index 8fdf61dbf7..a387c31a2a 100644
--- a/cypress/integration/homepage.spec.ts
+++ b/cypress/e2e/homepage.cy.ts
@@ -6,8 +6,8 @@ describe('Homepage', () => {
cy.visit('/');
});
- it('should display translated title "DSpace Angular :: Home"', () => {
- cy.title().should('eq', 'DSpace Angular :: Home');
+ it('should display translated title "DSpace Repository :: Home"', () => {
+ cy.title().should('eq', 'DSpace Repository :: Home');
});
it('should contain a news section', () => {
diff --git a/cypress/integration/item-page.spec.ts b/cypress/e2e/item-page.cy.ts
similarity index 76%
rename from cypress/integration/item-page.spec.ts
rename to cypress/e2e/item-page.cy.ts
index 6a454b678d..9eed711776 100644
--- a/cypress/integration/item-page.spec.ts
+++ b/cypress/e2e/item-page.cy.ts
@@ -1,10 +1,10 @@
import { Options } from 'cypress-axe';
-import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
+import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Item Page', () => {
- const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
- const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
+ const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION);
+ const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
it('should redirect to the entity page when navigating to an item page', () => {
@@ -16,7 +16,7 @@ describe('Item Page', () => {
cy.visit(ENTITYPAGE);
// tag must be loaded
- cy.get('ds-item-page').should('exist');
+ cy.get('ds-item-page').should('be.visible');
// Analyze for accessibility issues
// Disable heading-order checks until it is fixed
diff --git a/cypress/integration/item-statistics.spec.ts b/cypress/e2e/item-statistics.cy.ts
similarity index 51%
rename from cypress/integration/item-statistics.spec.ts
rename to cypress/e2e/item-statistics.cy.ts
index 66ebc228db..9b90cb24af 100644
--- a/cypress/integration/item-statistics.spec.ts
+++ b/cypress/e2e/item-statistics.cy.ts
@@ -1,36 +1,41 @@
-import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
+import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Item Statistics Page', () => {
- const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
+ const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION);
it('should load if you click on "Statistics" from an Item/Entity page', () => {
- cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
+ cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE);
- cy.get('ds-item-statistics-page').should('exist');
+ cy.get('ds-item-statistics-page').should('be.visible');
cy.get('ds-item-page').should('not.exist');
});
it('should contain a "Total visits" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
- cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
+ cy.get('table[data-test="TotalVisits"]').should('be.visible');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
- cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
+ // Check just for existence because this table is empty in CI environment as it's historical data
+ cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(ITEMSTATISTICSPAGE);
// tag must be loaded
- cy.get('ds-item-statistics-page').should('exist');
+ cy.get('ds-item-statistics-page').should('be.visible');
+
+ // Verify / wait until "Total Visits" table's label is non-empty
+ // (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
+ cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
// Analyze for accessibility issues
testA11y('ds-item-statistics-page');
diff --git a/cypress/integration/login-modal.spec.ts b/cypress/e2e/login-modal.cy.ts
similarity index 97%
rename from cypress/integration/login-modal.spec.ts
rename to cypress/e2e/login-modal.cy.ts
index fece28b425..b169634cfa 100644
--- a/cypress/integration/login-modal.spec.ts
+++ b/cypress/e2e/login-modal.cy.ts
@@ -1,4 +1,4 @@
-import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
+import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
const page = {
openLoginMenu() {
@@ -36,7 +36,7 @@ const page = {
describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => {
- const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
+ const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
cy.visit(ENTITYPAGE);
// Login menu should exist
diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/e2e/my-dspace.cy.ts
similarity index 86%
rename from cypress/integration/my-dspace.spec.ts
rename to cypress/e2e/my-dspace.cy.ts
index fa923dbcbc..79786c298a 100644
--- a/cypress/integration/my-dspace.spec.ts
+++ b/cypress/e2e/my-dspace.cy.ts
@@ -1,14 +1,15 @@
import { Options } from 'cypress-axe';
-import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support';
+import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('My DSpace page', () => {
it('should display recent submissions and pass accessibility tests', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
-
cy.visit('/mydspace');
- cy.get('ds-my-dspace-page').should('exist');
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
+
+ cy.get('ds-my-dspace-page').should('be.visible');
// At least one recent submission should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
@@ -36,16 +37,17 @@ describe('My DSpace page', () => {
});
it('should have a working detailed view that passes accessibility tests', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
-
cy.visit('/mydspace');
- cy.get('ds-my-dspace-page').should('exist');
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
+
+ cy.get('ds-my-dspace-page').should('be.visible');
// Click button in sidebar to display detailed view
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
- cy.get('ds-object-detail').should('exist');
+ cy.get('ds-object-detail').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-my-dspace-page',
@@ -61,9 +63,11 @@ describe('My DSpace page', () => {
// NOTE: Deleting existing submissions is exercised by submission.spec.ts
it('should let you start a new submission & edit in-progress submissions', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
+
// Open the New Submission dropdown
cy.get('button[data-test="submission-dropdown"]').click();
// Click on the "Item" type in that dropdown
@@ -76,7 +80,7 @@ describe('My DSpace page', () => {
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
// Click on the button matching that known Collection name
- cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click();
+ cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click();
// New URL should include /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems');
@@ -131,9 +135,11 @@ describe('My DSpace page', () => {
});
it('should let you import from external sources', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
+
// Open the New Import dropdown
cy.get('button[data-test="import-dropdown"]').click();
// Click on the "Item" type in that dropdown
diff --git a/cypress/integration/pagenotfound.spec.ts b/cypress/e2e/pagenotfound.cy.ts
similarity index 89%
rename from cypress/integration/pagenotfound.spec.ts
rename to cypress/e2e/pagenotfound.cy.ts
index 48520bcaa3..43e3c3af24 100644
--- a/cypress/integration/pagenotfound.spec.ts
+++ b/cypress/e2e/pagenotfound.cy.ts
@@ -2,7 +2,7 @@ describe('PageNotFound', () => {
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
- cy.get('ds-pagenotfound').should('exist');
+ cy.get('ds-pagenotfound').should('be.visible');
});
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
diff --git a/cypress/integration/search-navbar.spec.ts b/cypress/e2e/search-navbar.cy.ts
similarity index 92%
rename from cypress/integration/search-navbar.spec.ts
rename to cypress/e2e/search-navbar.cy.ts
index babd9b9dfd..648db17fe6 100644
--- a/cypress/integration/search-navbar.spec.ts
+++ b/cypress/e2e/search-navbar.cy.ts
@@ -1,4 +1,4 @@
-import { TEST_SEARCH_TERM } from 'cypress/support';
+import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
const page = {
fillOutQueryInNavBar(query) {
@@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter();
// New URL should include query param
- cy.url().should('include', 'query=' + query);
+ cy.url().should('include', 'query='.concat(query));
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
@@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter();
// New URL should include query param
- cy.url().should('include', 'query=' + query);
+ cy.url().should('include', 'query='.concat(query));
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
@@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => {
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingIcon();
// New URL should include query param
- cy.url().should('include', 'query=' + query);
+ cy.url().should('include', 'query='.concat(query));
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
diff --git a/cypress/integration/search-page.spec.ts b/cypress/e2e/search-page.cy.ts
similarity index 89%
rename from cypress/integration/search-page.spec.ts
rename to cypress/e2e/search-page.cy.ts
index 623c370c56..24519cc236 100644
--- a/cypress/integration/search-page.spec.ts
+++ b/cypress/e2e/search-page.cy.ts
@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe';
-import { TEST_SEARCH_TERM } from 'cypress/support';
+import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => {
@@ -13,11 +13,11 @@ describe('Search Page', () => {
});
it('should load results and pass accessibility tests', () => {
- cy.visit('/search?query=' + TEST_SEARCH_TERM);
+ cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
// tag must be loaded
- cy.get('ds-search-page').should('exist');
+ cy.get('ds-search-page').should('be.visible');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
@@ -45,13 +45,13 @@ describe('Search Page', () => {
});
it('should have a working grid view that passes accessibility tests', () => {
- cy.visit('/search?query=' + TEST_SEARCH_TERM);
+ cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
// Click button in sidebar to display grid view
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
// tag must be loaded
- cy.get('ds-search-page').should('exist');
+ cy.get('ds-search-page').should('be.visible');
// At least one grid object (card) should be displayed
cy.get('[data-test="grid-object"]').should('be.visible');
diff --git a/cypress/integration/submission.spec.ts b/cypress/e2e/submission.cy.ts
similarity index 87%
rename from cypress/integration/submission.spec.ts
rename to cypress/e2e/submission.cy.ts
index 009c50115b..ed10b2d13a 100644
--- a/cypress/integration/submission.spec.ts
+++ b/cypress/e2e/submission.cy.ts
@@ -1,15 +1,14 @@
-import { Options } from 'cypress-axe';
-import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support';
-import { testA11y } from 'cypress/support/utils';
+import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e';
describe('New Submission page', () => {
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
it('should create a new submission when using /submit path & pass accessibility', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
-
// Test that calling /submit with collection & entityType will create a new submission
- cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
+ cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
+
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
// Should redirect to /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems');
@@ -33,10 +32,11 @@ describe('New Submission page', () => {
});
it('should block submission & show errors if required fields are missing', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
-
// Create a new submission
- cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
+ cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
+
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
// Attempt an immediate deposit without filling out any fields
cy.get('button#deposit').click();
@@ -92,10 +92,11 @@ describe('New Submission page', () => {
});
it('should allow for deposit if all required fields completed & file uploaded', () => {
- cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
-
// Create a new submission
- cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
+ cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
+
+ // This page is restricted, so we will be shown the login form. Fill it out & submit.
+ cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
// Fill out all required fields (Title, Date)
cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests');
@@ -121,8 +122,6 @@ describe('New Submission page', () => {
// Wait for upload to complete before proceeding
cy.wait('@upload');
- // Close the upload success notice
- cy.get('[data-dismiss="alert"]').click({multiple: true});
// Wait for deposit button to not be disabled & click it.
cy.get('button#deposit').should('not.be.disabled').click();
diff --git a/cypress/integration/collection-statistics.spec.ts b/cypress/integration/collection-statistics.spec.ts
deleted file mode 100644
index 90b569c824..0000000000
--- a/cypress/integration/collection-statistics.spec.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { TEST_COLLECTION } from 'cypress/support';
-import { testA11y } from 'cypress/support/utils';
-
-describe('Collection Statistics Page', () => {
- const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
-
- it('should load if you click on "Statistics" from a Collection page', () => {
- cy.visit('/collections/' + TEST_COLLECTION);
- cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
- cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
- });
-
- it('should contain a "Total visits" section', () => {
- cy.visit(COLLECTIONSTATISTICSPAGE);
- cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
- });
-
- it('should contain a "Total visits per month" section', () => {
- cy.visit(COLLECTIONSTATISTICSPAGE);
- cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
- });
-
- it('should pass accessibility tests', () => {
- cy.visit(COLLECTIONSTATISTICSPAGE);
-
- // tag must be loaded
- cy.get('ds-collection-statistics-page').should('exist');
-
- // Analyze for accessibility issues
- testA11y('ds-collection-statistics-page');
- });
-});
diff --git a/cypress/integration/community-statistics.spec.ts b/cypress/integration/community-statistics.spec.ts
deleted file mode 100644
index cbf1783c0b..0000000000
--- a/cypress/integration/community-statistics.spec.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { TEST_COMMUNITY } from 'cypress/support';
-import { testA11y } from 'cypress/support/utils';
-
-describe('Community Statistics Page', () => {
- const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
-
- it('should load if you click on "Statistics" from a Community page', () => {
- cy.visit('/communities/' + TEST_COMMUNITY);
- cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
- cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
- });
-
- it('should contain a "Total visits" section', () => {
- cy.visit(COMMUNITYSTATISTICSPAGE);
- cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
- });
-
- it('should contain a "Total visits per month" section', () => {
- cy.visit(COMMUNITYSTATISTICSPAGE);
- cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
- });
-
- it('should pass accessibility tests', () => {
- cy.visit(COMMUNITYSTATISTICSPAGE);
-
- // tag must be loaded
- cy.get('ds-community-statistics-page').should('exist');
-
- // Analyze for accessibility issues
- testA11y('ds-community-statistics-page');
- });
-});
diff --git a/cypress/integration/homepage-statistics.spec.ts b/cypress/integration/homepage-statistics.spec.ts
deleted file mode 100644
index fe0311f87e..0000000000
--- a/cypress/integration/homepage-statistics.spec.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { testA11y } from 'cypress/support/utils';
-
-describe('Site Statistics Page', () => {
- it('should load if you click on "Statistics" from homepage', () => {
- cy.visit('/');
- cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
- cy.location('pathname').should('eq', '/statistics');
- });
-
- it('should pass accessibility tests', () => {
- cy.visit('/statistics');
-
- // tag must be loaded
- cy.get('ds-site-statistics-page').should('exist');
-
- // Analyze for accessibility issues
- testA11y('ds-site-statistics-page');
- });
-});
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index 30951d46f1..c70c4e37e1 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -4,12 +4,17 @@
// ***********************************************
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
-import { FALLBACK_TEST_REST_BASE_URL } from '.';
+import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
+
+// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
+// from the Angular UI's config.json. See 'login()'.
+export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
+export const FALLBACK_TEST_REST_DOMAIN = 'localhost';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work
-// tslint:disable-next-line:no-namespace
declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
@@ -19,6 +24,23 @@ declare global {
* @param password password to login as
*/
login(email: string, password: string): typeof login;
+
+ /**
+ * Login via form before accessing the next page. Useful to fill out login
+ * form when a cy.visit() call is to an a page which requires authentication.
+ * @param email email to login as
+ * @param password password to login as
+ */
+ loginViaForm(email: string, password: string): typeof loginViaForm;
+
+ /**
+ * Generate view event for given object. Useful for testing statistics pages with
+ * pre-generated statistics. This just generates a single "hit", but can be called multiple times to
+ * generate multiple hits.
+ * @param uuid UUID of object
+ * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
+ */
+ generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
}
}
}
@@ -26,6 +48,8 @@ declare global {
/**
* Login user via REST API directly, and pass authentication token to UI via
* the UI's dsAuthInfo cookie.
+ * WARNING: WHILE THIS METHOD WORKS, OCCASIONALLY RANDOM AUTHENTICATION ERRORS OCCUR.
+ * At this time "loginViaForm()" seems more consistent/stable.
* @param email email to login as
* @param password password to login as
*/
@@ -43,41 +67,128 @@ function login(email: string, password: string): void {
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
- console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl);
+ //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
baseRestUrl = config.rest.baseUrl;
}
- // To login via REST, first we have to do a GET to obtain a valid CSRF token
- cy.request( baseRestUrl + '/api/authn/status' )
- .then((response) => {
- // We should receive a CSRF token returned in a response header
- expect(response.headers).to.have.property('dspace-xsrf-token');
- const csrfToken = response.headers['dspace-xsrf-token'];
+ // Now find domain of our REST API, again with a fallback.
+ let baseDomain = FALLBACK_TEST_REST_DOMAIN;
+ if (!config.rest.host) {
+ console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
+ } else {
+ baseDomain = config.rest.host;
+ }
- // Now, send login POST request including that CSRF token
- cy.request({
- method: 'POST',
- url: baseRestUrl + '/api/authn/login',
- headers: { 'X-XSRF-TOKEN' : csrfToken},
- form: true, // indicates the body should be form urlencoded
- body: { user: email, password: password }
- }).then((resp) => {
- // We expect a successful login
- expect(resp.status).to.eq(200);
- // We expect to have a valid authorization header returned (with our auth token)
- expect(resp.headers).to.have.property('authorization');
+ // Create a fake CSRF Token. Set it in the required server-side cookie
+ const csrfToken = 'fakeLoginCSRFToken';
+ cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
- // Initialize our AuthTokenInfo object from the authorization header.
- const authheader = resp.headers.authorization as string;
- const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
+ // Now, send login POST request including that CSRF token
+ cy.request({
+ method: 'POST',
+ url: baseRestUrl + '/api/authn/login',
+ headers: { [XSRF_REQUEST_HEADER]: csrfToken},
+ form: true, // indicates the body should be form urlencoded
+ body: { user: email, password: password }
+ }).then((resp) => {
+ // We expect a successful login
+ expect(resp.status).to.eq(200);
+ // We expect to have a valid authorization header returned (with our auth token)
+ expect(resp.headers).to.have.property('authorization');
- // Save our AuthTokenInfo object to our dsAuthInfo UI cookie
- // This ensures the UI will recognize we are logged in on next "visit()"
- cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
- });
+ // Initialize our AuthTokenInfo object from the authorization header.
+ const authheader = resp.headers.authorization as string;
+ const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
+
+ // Save our AuthTokenInfo object to our dsAuthInfo UI cookie
+ // This ensures the UI will recognize we are logged in on next "visit()"
+ cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
});
+ // Remove cookie with fake CSRF token, as it's no longer needed
+ cy.clearCookie(DSPACE_XSRF_COOKIE);
});
}
// Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login);
+
+/**
+ * Login user via displayed login form
+ * @param email email to login as
+ * @param password password to login as
+ */
+function loginViaForm(email: string, password: string): void {
+ // Enter email
+ cy.get('ds-log-in [data-test="email"]').type(email);
+ // Enter password
+ cy.get('ds-log-in [data-test="password"]').type(password);
+ // Click login button
+ cy.get('ds-log-in [data-test="login-button"]').click();
+}
+// Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
+Cypress.Commands.add('loginViaForm', loginViaForm);
+
+
+/**
+ * Generate statistic view event for given object. Useful for testing statistics pages with
+ * pre-generated statistics. This just generates a single "hit", but can be called multiple times to
+ * generate multiple hits.
+ *
+ * NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend
+ * (as it is in our docker-compose-ci.yml used in CI).
+ * Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers.
+ * @param uuid UUID of object
+ * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
+ */
+function generateViewEvent(uuid: string, dsoType: string): void {
+ // Cypress doesn't have access to the running application in Node.js.
+ // So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
+ // Instead, we'll read our running application's config.json, which contains the configs &
+ // is regenerated at runtime each time the Angular UI application starts up.
+ cy.task('readUIConfig').then((str: string) => {
+ // Parse config into a JSON object
+ const config = JSON.parse(str);
+
+ // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
+ let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
+ if (!config.rest.baseUrl) {
+ console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
+ } else {
+ baseRestUrl = config.rest.baseUrl;
+ }
+
+ // Now find domain of our REST API, again with a fallback.
+ let baseDomain = FALLBACK_TEST_REST_DOMAIN;
+ if (!config.rest.host) {
+ console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
+ } else {
+ baseDomain = config.rest.host;
+ }
+
+ // Create a fake CSRF Token. Set it in the required server-side cookie
+ const csrfToken = 'fakeGenerateViewEventCSRFToken';
+ cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
+
+ // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
+ cy.request({
+ method: 'POST',
+ url: baseRestUrl + '/api/statistics/viewevents',
+ headers: {
+ [XSRF_REQUEST_HEADER] : csrfToken,
+ // use a known public IP address to avoid being seen as a "bot"
+ 'X-Forwarded-For': '1.1.1.1',
+ },
+ //form: true, // indicates the body should be form urlencoded
+ body: { targetId: uuid, targetType: dsoType },
+ }).then((resp) => {
+ // We expect a 201 (which means statistics event was created)
+ expect(resp.status).to.eq(201);
+ });
+
+ // Remove cookie with fake CSRF token, as it's no longer needed
+ cy.clearCookie(DSPACE_XSRF_COOKIE);
+ });
+}
+// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
+Cypress.Commands.add('generateViewEvent', generateViewEvent);
+
diff --git a/cypress/support/index.ts b/cypress/support/e2e.ts
similarity index 92%
rename from cypress/support/index.ts
rename to cypress/support/e2e.ts
index 70da23f044..dd7ee1824c 100644
--- a/cypress/support/index.ts
+++ b/cypress/support/e2e.ts
@@ -30,11 +30,11 @@ beforeEach(() => {
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test.
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test.
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
-afterEach(() => {
+/*afterEach(() => {
cy.window().then((win) => {
win.location.href = 'about:blank';
});
-});
+});*/
// Global constants used in tests
@@ -43,10 +43,6 @@ afterEach(() => {
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment)
-// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
-// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts
-export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
-
// Admin account used for administrative tests
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
@@ -61,3 +57,10 @@ export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLE
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
+
+
+// USEFUL REGEX for testing
+
+// Match any string that contains at least one non-space character
+// Can be used with "contains()" to determine if an element has a non-empty text value
+export const REGEX_MATCH_NON_EMPTY_TEXT = /^(?!\s*$).+/;
diff --git a/docker/README.md b/docker/README.md
index 1a9fee0a81..42deb793f9 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -6,7 +6,20 @@
If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario.
***
-## 'Dockerfile' in root directory
+## Overview
+The scripts in this directory can be used to start the DSpace User Interface (frontend) in Docker.
+Optionally, the backend (REST API) might also be started in Docker.
+
+For additional options/settings in starting the backend (REST API) in Docker, see the Docker Compose
+documentation for the backend: https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md
+
+## Root directory
+
+The root directory of this project contains all the Dockerfiles which may be referenced by
+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'
```
@@ -20,7 +33,18 @@ Admins to our DockerHub repo can manually publish with the following command.
docker push dspace/dspace-angular:dspace-7_x
```
-## docker directory
+### Dockerfile.dist
+
+The `Dockerfile.dist` is used to generate a *production* build and runtime environment.
+
+```bash
+# build the latest image
+docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
+```
+
+A default/demo version of this image is built *automatically*.
+
+## 'docker' directory
- docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
- docker-compose-rest.yml
@@ -45,23 +69,47 @@ docker-compose -f docker/docker-compose.yml build
## To start DSpace (REST and Angular) from your branch
+This command provides a quick way to start both the frontend & backend from this single codebase
```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
```
+Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section.
+
+
## Run DSpace REST and DSpace Angular from local branches.
+
+This section assumes that you have clones *both* the 'DSpace/DSpace' and 'DSpace/dspace-angular' GitHub
+repositories. When both are available locally, you can spin up both in Docker and have them work together.
+
_The system will be started in 2 steps. Each step shares the same docker network._
-From DSpace/DSpace (build as needed)
+From 'DSpace/DSpace' clone (build first as needed):
```
docker-compose -p d7 up -d
```
-From DSpace/DSpace-angular
+NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md).
+
+From 'DSpace/dspace-angular' clone (build first as needed)
```
docker-compose -p d7 -f docker/docker-compose.yml up -d
```
+At this point, you should be able to access the UI from http://localhost:4000,
+and the backend at http://localhost:8080/server/
+
+## Run DSpace Angular dist build with DSpace Demo site backend
+
+This allows you to run the Angular UI in *production* mode, pointing it at the demo backend
+(https://api7.dspace.org/server/).
+
+```
+docker-compose -f docker/docker-compose-dist.yml pull
+docker-compose -f docker/docker-compose-dist.yml build
+docker-compose -p d7 -f docker/docker-compose-dist.yml up -d
+```
+
## Ingest test data from AIPDIR
Create an administrator
@@ -87,9 +135,10 @@ Load assetstore content and trigger a re-index of the repository
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
```
-## End to end testing of the rest api (runs in travis).
-_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
+## End to end testing of the REST API (runs in GitHub Actions CI).
+_In this instance, only the REST api runs in Docker using the Entities dataset. GitHub Actions will perform CI testing of Angular using Node to drive the tests. See `.github/workflows/build.yml` for more details._
+This command is only really useful for testing our Continuous Integration process.
```
-docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
+docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d
```
diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml
index dbe9500499..9ec8fe664a 100644
--- a/docker/docker-compose-ci.yml
+++ b/docker/docker-compose-ci.yml
@@ -24,12 +24,15 @@ services:
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir, dspace.server.url and dspace.ui.url
dspace__P__dir: /dspace
- dspace__P__server__P__url: http://localhost:8080/server
- dspace__P__ui__P__url: http://localhost:4000
+ dspace__P__server__P__url: http://127.0.0.1:8080/server
+ dspace__P__ui__P__url: http://127.0.0.1:4000
# db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
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'
depends_on:
- dspacedb
image: dspace/dspace:dspace-7_x-test
diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml
new file mode 100644
index 0000000000..1c75539da9
--- /dev/null
+++ b/docker/docker-compose-dist.yml
@@ -0,0 +1,40 @@
+#
+# The contents of this file are subject to the license and copyright
+# detailed in the LICENSE and NOTICE files at the root of the source
+# tree and available online at
+#
+# http://www.dspace.org/license/
+#
+
+# Docker Compose for running the DSpace Angular UI dist build
+# for previewing with the DSpace Demo site backend
+version: '3.7'
+networks:
+ dspacenet:
+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: api7.dspace.org
+ DSPACE_REST_PORT: 443
+ DSPACE_REST_NAMESPACE: /server
+ image: dspace/dspace-angular:dspace-7_x-dist
+ build:
+ context: ..
+ dockerfile: Dockerfile.dist
+ networks:
+ 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 b73f1b7a39..e5f62600e7 100644
--- a/docker/docker-compose-rest.yml
+++ b/docker/docker-compose-rest.yml
@@ -39,7 +39,7 @@ services:
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0'
- image: dspace/dspace:dspace-7_x-test
+ image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}"
depends_on:
- dspacedb
networks:
@@ -82,8 +82,7 @@ services:
# DSpace Solr container
dspacesolr:
container_name: dspacesolr
- # Uses official Solr image at https://hub.docker.com/_/solr/
- image: solr:8.11-slim
+ image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}"
# Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on:
- dspace
@@ -96,28 +95,26 @@ services:
tty: true
working_dir: /var/solr/data
volumes:
- # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder)
- # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume
- - solr_configs:/opt/solr/server/solr/configsets/dspace
# Keep Solr data directory between reboots
- solr_data:/var/solr/data
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
- # * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core
- # to the latest configs. If it's a newly created core, this is a no-op.
+ # * Second, copy configsets to this core:
+ # Updates to Solr configs require the container to be rebuilt/restarted:
+ # `docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr`
entrypoint:
- /bin/bash
- '-c'
- |
init-var-solr
- precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
- cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
- precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
- cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai
- precreate-core search /opt/solr/server/solr/configsets/dspace/search
- cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search
- precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
- cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics
+ precreate-core authority /opt/solr/server/solr/configsets/authority
+ cp -r /opt/solr/server/solr/configsets/authority/* authority
+ precreate-core oai /opt/solr/server/solr/configsets/oai
+ cp -r /opt/solr/server/solr/configsets/oai/* oai
+ precreate-core search /opt/solr/server/solr/configsets/search
+ cp -r /opt/solr/server/solr/configsets/search/* search
+ precreate-core statistics /opt/solr/server/solr/configsets/statistics
+ cp -r /opt/solr/server/solr/configsets/statistics/* statistics
exec solr -f
volumes:
assetstore:
diff --git a/docker/dspace-ui.json b/docker/dspace-ui.json
new file mode 100644
index 0000000000..0758679ab8
--- /dev/null
+++ b/docker/dspace-ui.json
@@ -0,0 +1,11 @@
+{
+ "apps": [
+ {
+ "name": "dspace-ui",
+ "cwd": "/app",
+ "script": "dist/server/main.js",
+ "instances": "max",
+ "exec_mode": "cluster"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 1b1c7a4b00..719b13b23b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dspace-angular",
- "version": "7.4.0",
+ "version": "7.6.0",
"scripts": {
"ng": "ng",
"config:watch": "nodemon",
@@ -17,9 +17,9 @@
"build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
- "test": "ng test --sourceMap=true --watch=false --configuration test",
- "test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"",
- "test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
+ "test": "ng test --source-map=true --watch=false --configuration test",
+ "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
+ "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint",
"lint-fix": "ng lint --fix=true",
"e2e": "ng e2e",
@@ -30,8 +30,9 @@
"clean:log": "rimraf *.log*",
"clean:json": "rimraf *.records.json",
"clean:node": "rimraf node_modules",
+ "clean:cli": "rimraf .angular/cache",
"clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json",
- "clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:node",
+ "clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:cli && yarn run clean:node",
"sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
@@ -54,169 +55,153 @@
"ts-node": "10.2.1"
},
"dependencies": {
- "@angular/animations": "~13.2.6",
- "@angular/cdk": "^13.2.6",
- "@angular/common": "~13.2.6",
- "@angular/compiler": "~13.2.6",
- "@angular/core": "~13.2.6",
- "@angular/forms": "~13.2.6",
- "@angular/localize": "13.2.6",
- "@angular/platform-browser": "~13.2.6",
- "@angular/platform-browser-dynamic": "~13.2.6",
- "@angular/platform-server": "~13.2.6",
- "@angular/router": "~13.2.6",
- "@babel/runtime": "^7.17.2",
+ "@angular/animations": "^15.2.8",
+ "@angular/cdk": "^15.2.8",
+ "@angular/common": "^15.2.8",
+ "@angular/compiler": "^15.2.8",
+ "@angular/core": "^15.2.8",
+ "@angular/forms": "^15.2.8",
+ "@angular/localize": "15.2.8",
+ "@angular/platform-browser": "^15.2.8",
+ "@angular/platform-browser-dynamic": "^15.2.8",
+ "@angular/platform-server": "^15.2.8",
+ "@angular/router": "^15.2.8",
+ "@babel/runtime": "7.21.0",
"@kolkov/ngx-gallery": "^2.0.1",
"@material-ui/core": "^4.11.0",
- "@material-ui/icons": "^4.9.1",
+ "@material-ui/icons": "^4.11.3",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^15.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
- "@ngrx/effects": "^13.0.2",
- "@ngrx/router-store": "^13.0.2",
- "@ngrx/store": "^13.0.2",
- "@nguniversal/express-engine": "^13.0.2",
- "@ngx-translate/core": "^13.0.0",
- "@nicky-lenaers/ngx-scroll-to": "^9.0.0",
+ "@ngrx/effects": "^15.4.0",
+ "@ngrx/router-store": "^15.4.0",
+ "@ngrx/store": "^15.4.0",
+ "@nguniversal/express-engine": "^15.2.1",
+ "@ngx-translate/core": "^14.0.0",
+ "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0",
- "angulartics2": "^12.0.0",
+ "angulartics2": "^12.2.0",
"axios": "^0.27.2",
- "bootstrap": "4.3.1",
- "caniuse-lite": "^1.0.30001165",
+ "bootstrap": "^4.6.1",
"cerialize": "0.1.18",
- "cli-progress": "^3.8.0",
+ "cli-progress": "^3.12.0",
+ "colors": "^1.4.0",
"compression": "^1.7.4",
- "cookie-parser": "1.4.5",
- "core-js": "^3.7.0",
- "deepmerge": "^4.2.2",
- "express": "^4.17.1",
+ "cookie-parser": "1.4.6",
+ "core-js": "^3.30.1",
+ "date-fns": "^2.29.3",
+ "date-fns-tz": "^1.3.7",
+ "deepmerge": "^4.3.1",
+ "ejs": "^3.1.9",
+ "express": "^4.18.2",
"express-rate-limit": "^5.1.3",
- "fast-json-patch": "^3.0.0-1",
- "file-saver": "^2.0.5",
+ "fast-json-patch": "^3.1.1",
"filesize": "^6.1.0",
- "font-awesome": "4.7.0",
"http-proxy-middleware": "^1.0.5",
- "https": "1.0.0",
+ "isbot": "^3.6.10",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
- "json5": "^2.1.3",
- "jsonschema": "1.4.0",
+ "json5": "^2.2.3",
+ "jsonschema": "1.4.1",
"jwt-decode": "^3.1.2",
- "klaro": "^0.7.10",
+ "klaro": "^0.7.18",
"lodash": "^4.17.21",
+ "lru-cache": "^7.14.1",
"markdown-it": "^13.0.1",
- "markdown-it-mathjax3": "^4.3.1",
+ "markdown-it-mathjax3": "^4.3.2",
"mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
- "moment": "^2.29.4",
"morgan": "^1.10.0",
- "ng-mocks": "^13.1.1",
+ "ng-mocks": "^14.10.0",
"ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.3",
- "ngx-infinite-scroll": "^10.0.1",
- "ngx-moment": "^5.0.0",
- "ngx-pagination": "5.0.0",
+ "ngx-infinite-scroll": "^15.0.0",
+ "ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0",
- "ngx-ui-switch": "^11.0.1",
+ "ngx-ui-switch": "^14.0.3",
"nouislider": "^14.6.3",
- "pem": "1.14.4",
- "postcss-cli": "^9.1.0",
- "prop-types": "^15.7.2",
- "react-copy-to-clipboard": "^5.0.1",
+ "pem": "1.14.7",
+ "prop-types": "^15.8.1",
+ "react-copy-to-clipboard": "^5.1.0",
"reflect-metadata": "^0.1.13",
- "rxjs": "^7.5.5",
- "sanitize-html": "^2.7.2",
- "sortablejs": "1.13.0",
- "tslib": "^2.0.0",
- "url-parse": "^1.5.6",
+ "rxjs": "^7.8.0",
+ "sanitize-html": "^2.10.0",
+ "sortablejs": "1.15.0",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "~0.11.5"
},
"devDependencies": {
- "@angular-builders/custom-webpack": "~13.1.0",
- "@angular-devkit/build-angular": "~13.2.6",
- "@angular-eslint/builder": "13.1.0",
- "@angular-eslint/eslint-plugin": "13.1.0",
- "@angular-eslint/eslint-plugin-template": "13.1.0",
- "@angular-eslint/schematics": "13.1.0",
- "@angular-eslint/template-parser": "13.1.0",
- "@angular/cli": "~13.2.6",
- "@angular/compiler-cli": "~13.2.6",
- "@angular/language-service": "~13.2.6",
+ "@angular-builders/custom-webpack": "~15.0.0",
+ "@angular-devkit/build-angular": "^15.2.6",
+ "@angular-eslint/builder": "15.2.1",
+ "@angular-eslint/eslint-plugin": "15.2.1",
+ "@angular-eslint/eslint-plugin-template": "15.2.1",
+ "@angular-eslint/schematics": "15.2.1",
+ "@angular-eslint/template-parser": "15.2.1",
+ "@angular/cli": "^15.2.6",
+ "@angular/compiler-cli": "^15.2.8",
+ "@angular/language-service": "^15.2.8",
"@cypress/schematic": "^1.5.0",
- "@fortawesome/fontawesome-free": "^5.5.0",
- "@ngrx/store-devtools": "^13.0.2",
- "@ngtools/webpack": "^13.2.6",
- "@nguniversal/builders": "^13.0.2",
+ "@fortawesome/fontawesome-free": "^6.4.0",
+ "@ngrx/store-devtools": "^15.4.0",
+ "@ngtools/webpack": "^15.2.6",
+ "@nguniversal/builders": "^15.2.1",
"@types/deep-freeze": "0.1.2",
- "@types/express": "^4.17.9",
- "@types/file-saver": "^2.0.1",
+ "@types/ejs": "^3.1.2",
+ "@types/express": "^4.17.17",
"@types/jasmine": "~3.6.0",
- "@types/jasminewd2": "~2.0.8",
"@types/js-cookie": "2.2.6",
- "@types/lodash": "^4.14.165",
+ "@types/lodash": "^4.14.194",
"@types/node": "^14.14.9",
- "@types/sanitize-html": "^2.6.2",
- "@typescript-eslint/eslint-plugin": "5.11.0",
- "@typescript-eslint/parser": "5.11.0",
- "axe-core": "^4.3.3",
+ "@types/sanitize-html": "^2.9.0",
+ "@typescript-eslint/eslint-plugin": "^5.59.1",
+ "@typescript-eslint/parser": "^5.59.1",
+ "axe-core": "^4.7.0",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
- "css-loader": "^6.2.0",
- "css-minimizer-webpack-plugin": "^3.4.1",
- "cssnano": "^5.0.6",
- "cypress": "9.5.1",
- "cypress-axe": "^0.14.0",
- "debug-loader": "^0.0.1",
+ "cypress": "12.10.0",
+ "cypress-axe": "^1.4.0",
"deep-freeze": "0.0.1",
- "dotenv": "^8.2.0",
- "eslint": "^8.2.0",
- "eslint-plugin-deprecation": "^1.3.2",
- "eslint-plugin-import": "^2.25.4",
- "eslint-plugin-jsdoc": "^38.0.6",
+ "eslint": "^8.39.0",
+ "eslint-plugin-deprecation": "^1.4.1",
+ "eslint-plugin-import": "^2.27.5",
+ "eslint-plugin-jsdoc": "^39.6.4",
+ "eslint-plugin-jsonc": "^2.6.0",
+ "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "^2.0.0",
- "express-static-gzip": "^2.1.5",
- "fork-ts-checker-webpack-plugin": "^6.0.3",
- "html-loader": "^1.3.2",
+ "express-static-gzip": "^2.1.7",
"jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
- "jasmine-spec-reporter": "~5.0.0",
- "karma": "^6.3.14",
- "karma-chrome-launcher": "~3.1.0",
- "karma-coverage-istanbul-reporter": "~3.0.2",
+ "karma": "^6.4.2",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5",
"ngx-mask": "^13.1.7",
- "nodemon": "^2.0.15",
- "postcss": "^8.1",
+ "nodemon": "^2.0.22",
+ "postcss": "^8.4",
"postcss-apply": "0.12.0",
"postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2",
"postcss-responsive-type": "1.0.0",
- "protractor": "^7.0.0",
- "protractor-istanbul-plugin": "2.0.0",
- "raw-loader": "0.5.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"rimraf": "^3.0.2",
"rxjs-spy": "^8.0.2",
- "sass": "~1.32.6",
+ "sass": "~1.62.0",
"sass-loader": "^12.6.0",
- "sass-resources-loader": "^2.1.1",
- "string-replace-loader": "^3.1.0",
- "terser-webpack-plugin": "^2.3.1",
- "ts-loader": "^5.2.0",
+ "sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2",
- "typescript": "~4.5.5",
- "webpack": "^5.69.1",
- "webpack-bundle-analyzer": "^4.4.0",
+ "typescript": "~4.8.4",
+ "webpack": "5.76.1",
+ "webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^4.2.0",
- "webpack-dev-server": "^4.5.0"
+ "webpack-dev-server": "^4.13.3"
}
}
diff --git a/scripts/base-href.ts b/scripts/base-href.ts
index aee547b46d..7212e1c516 100644
--- a/scripts/base-href.ts
+++ b/scripts/base-href.ts
@@ -1,4 +1,4 @@
-import * as fs from 'fs';
+import { existsSync, writeFileSync } from 'fs';
import { join } from 'path';
import { AppConfig } from '../src/config/app-config.interface';
@@ -16,7 +16,7 @@ const appConfig: AppConfig = buildAppConfig();
const angularJsonPath = join(process.cwd(), 'angular.json');
-if (!fs.existsSync(angularJsonPath)) {
+if (!existsSync(angularJsonPath)) {
console.error(`Error:\n${angularJsonPath} does not exist\n`);
process.exit(1);
}
@@ -30,7 +30,7 @@ try {
angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref;
- fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n');
+ writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n');
} catch (e) {
console.error(e);
}
diff --git a/scripts/env-to-yaml.ts b/scripts/env-to-yaml.ts
index c2dd1cf0ca..6e8153f4c1 100644
--- a/scripts/env-to-yaml.ts
+++ b/scripts/env-to-yaml.ts
@@ -1,5 +1,5 @@
-import * as fs from 'fs';
-import * as yaml from 'js-yaml';
+import { existsSync, writeFileSync } from 'fs';
+import { dump } from 'js-yaml';
import { join } from 'path';
/**
@@ -18,7 +18,7 @@ if (args[0] === undefined) {
const envFullPath = join(process.cwd(), args[0]);
-if (!fs.existsSync(envFullPath)) {
+if (!existsSync(envFullPath)) {
console.error(`Error:\n${envFullPath} does not exist\n`);
process.exit(1);
}
@@ -26,10 +26,10 @@ if (!fs.existsSync(envFullPath)) {
try {
const env = require(envFullPath).environment;
- const config = yaml.dump(env);
+ const config = dump(env);
if (args[1]) {
const ymlFullPath = join(process.cwd(), args[1]);
- fs.writeFileSync(ymlFullPath, config);
+ writeFileSync(ymlFullPath, config);
} else {
console.log(config);
}
diff --git a/scripts/serve.ts b/scripts/serve.ts
index 99bfc822d4..ee8570a45c 100644
--- a/scripts/serve.ts
+++ b/scripts/serve.ts
@@ -1,4 +1,4 @@
-import * as child from 'child_process';
+import { spawn } from 'child_process';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
@@ -9,7 +9,7 @@ const appConfig: AppConfig = buildAppConfig();
* Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl
* Any CLI arguments given to this script are patched through to `ng serve` as well.
*/
-child.spawn(
+spawn(
`ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl} ${process.argv.slice(2).join(' ')} --configuration development`,
{ stdio: 'inherit', shell: true }
);
diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts
index 51822cf939..9066777c42 100644
--- a/scripts/test-rest.ts
+++ b/scripts/test-rest.ts
@@ -1,5 +1,5 @@
-import * as http from 'http';
-import * as https from 'https';
+import { request } from 'http';
+import { request as https_request } from 'https';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
@@ -20,7 +20,7 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`);
// If SSL enabled, test via HTTPS, else via HTTP
if (appConfig.rest.ssl) {
- const req = https.request(restUrl, (res) => {
+ const req = https_request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
// We will keep reading data until the 'end' event fires.
// This ensures we don't just read the first chunk.
@@ -39,7 +39,7 @@ if (appConfig.rest.ssl) {
req.end();
} else {
- const req = http.request(restUrl, (res) => {
+ const req = request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
// We will keep reading data until the 'end' event fires.
// This ensures we don't just read the first chunk.
diff --git a/scripts/webpack.js b/scripts/webpack.js
deleted file mode 100644
index 93f17b4619..0000000000
--- a/scripts/webpack.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const path = require('path');
-const child_process = require('child_process');
-
-const heapSize = 4096;
-const webpackPath = path.join('node_modules', 'webpack', 'bin', 'webpack.js');
-
-const params = [
- '--max_old_space_size=' + heapSize,
- webpackPath,
- ...process.argv.slice(2)
-];
-
-child_process.spawn('node', params, { stdio:'inherit' });
diff --git a/server.ts b/server.ts
index 81137ad56a..23327c2058 100644
--- a/server.ts
+++ b/server.ts
@@ -19,19 +19,23 @@ import 'zone.js/node';
import 'reflect-metadata';
import 'rxjs';
-import axios from 'axios';
-import * as pem from 'pem';
-import * as https from 'https';
+/* eslint-disable import/no-namespace */
import * as morgan from 'morgan';
import * as express from 'express';
-import * as bodyParser from 'body-parser';
+import * as ejs from 'ejs';
import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip';
+/* eslint-enable import/no-namespace */
+import axios from 'axios';
+import LRU from 'lru-cache';
+import isbot from 'isbot';
+import { createCertificate } from 'pem';
+import { createServer } from 'https';
+import { json } from 'body-parser';
-import { existsSync, readFileSync } from 'fs';
+import { readFileSync } from 'fs';
import { join } from 'path';
-import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core';
import { ngExpressEngine } from '@nguniversal/express-engine';
@@ -49,6 +53,8 @@ import { buildAppConfig } from './src/config/config.server';
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
+import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model';
+
/*
* Set path for the browser application's dist folder
@@ -57,12 +63,18 @@ const DIST_FOLDER = join(process.cwd(), 'dist/browser');
// Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
-const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
+const indexHtml = join(DIST_FOLDER, 'index.html');
const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
+// cache of SSR pages for known bots, only enabled in production mode
+let botCache: LRU;
+
+// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
+let anonymousCache: LRU;
+
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
@@ -83,10 +95,12 @@ export function app() {
/*
* If production mode is enabled in the environment file:
* - Enable Angular's production mode
+ * - Initialize caching of SSR rendered pages (if enabled in config.yml)
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/
if (environment.production) {
enableProdMode();
+ initCache();
server.use(compression({
// only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
@@ -102,15 +116,15 @@ export function app() {
/*
* Add cookie parser middleware
- * See [morgan](https://github.com/expressjs/cookie-parser)
+ * See [cookie-parser](https://github.com/expressjs/cookie-parser)
*/
server.use(cookieParser());
/*
- * Add parser for request bodies
- * See [morgan](https://github.com/expressjs/body-parser)
+ * Add JSON parser for request bodies
+ * See [body-parser](https://github.com/expressjs/body-parser)
*/
- server.use(bodyParser.json());
+ server.use(json());
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', (_, options, callback) =>
@@ -133,10 +147,23 @@ export function app() {
})(_, (options as any), callback)
);
+ server.engine('ejs', ejs.renderFile);
+
/*
* Register the view engines for html and ejs
*/
server.set('view engine', 'html');
+ server.set('view engine', 'ejs');
+
+ /**
+ * Serve the robots.txt ejs template, filling in the origin variable
+ */
+ server.get('/robots.txt', (req, res) => {
+ res.setHeader('content-type', 'text/plain');
+ res.render('assets/robots.txt.ejs', {
+ 'origin': req.protocol + '://' + req.headers.host
+ });
+ });
/*
* Set views folder path to directory where template files are stored
@@ -152,6 +179,15 @@ export function app() {
changeOrigin: true
}));
+ /**
+ * Proxy the linksets
+ */
+ router.use('/signposting**', createProxyMiddleware({
+ target: `${environment.rest.baseUrl}`,
+ pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
+ changeOrigin: true
+ }));
+
/**
* Checks if the rateLimiter property is present
* When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled.
@@ -169,7 +205,7 @@ export function app() {
* Serve static resources (images, i18n messages, …)
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
*/
- router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
+ router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
index: false,
enableBrotli: true,
orderPreference: ['br', 'gzip'],
@@ -185,8 +221,11 @@ export function app() {
*/
server.get('/app/health', healthCheck);
- // Register the ngApp callback function to handle incoming requests
- router.get('*', ngApp);
+ /**
+ * Default sending all incoming requests to ngApp() function, after first checking for a cached
+ * copy of the page (see cacheCheck())
+ */
+ router.get('*', cacheCheck, ngApp);
server.use(environment.ui.nameSpace, router);
@@ -198,60 +237,280 @@ export function app() {
*/
function ngApp(req, res) {
if (environment.universal.preboot) {
- res.render(indexHtml, {
- req,
- res,
- preboot: environment.universal.preboot,
- async: environment.universal.async,
- time: environment.universal.time,
- baseUrl: environment.ui.nameSpace,
- originUrl: environment.ui.baseUrl,
- requestUrl: req.originalUrl,
- providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
- }, (err, data) => {
- if (hasNoValue(err) && hasValue(data)) {
- res.locals.ssr = true; // mark response as SSR
- res.send(data);
- } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
- // When this error occurs we can't fall back to CSR because the response has already been
- // sent. These errors occur for various reasons in universal, not all of which are in our
- // control to solve.
- console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
- } else {
- console.warn('Error in SSR, serving for direct CSR.');
- if (hasValue(err)) {
- console.warn('Error details : ', err);
- }
- res.render(indexHtml, {
- req,
- providers: [{
- provide: APP_BASE_HREF,
- useValue: req.baseUrl
- }]
- });
- }
- });
+ // Render the page to user via SSR (server side rendering)
+ serverSideRender(req, res);
} else {
// If preboot is disabled, just serve the client
- console.log('Universal off, serving for direct CSR');
- res.render(indexHtml, {
- req,
- providers: [{
- provide: APP_BASE_HREF,
- useValue: req.baseUrl
- }]
+ console.log('Universal off, serving for direct client-side rendering (CSR)');
+ clientSideRender(req, res);
+ }
+}
+
+/**
+ * Render page content on server side using Angular SSR. By default this page content is
+ * returned to the user.
+ * @param req current request
+ * @param res current response
+ * @param sendToUser if true (default), send the rendered content to the user.
+ * If false, then only save this rendered content to the in-memory cache (to refresh cache).
+ */
+function serverSideRender(req, res, sendToUser: boolean = true) {
+ // Render the page via SSR (server side rendering)
+ res.render(indexHtml, {
+ req,
+ res,
+ preboot: environment.universal.preboot,
+ async: environment.universal.async,
+ time: environment.universal.time,
+ baseUrl: environment.ui.nameSpace,
+ originUrl: environment.ui.baseUrl,
+ requestUrl: req.originalUrl,
+ }, (err, data) => {
+ if (hasNoValue(err) && hasValue(data)) {
+ // save server side rendered page to cache (if any are enabled)
+ saveToCache(req, data);
+ if (sendToUser) {
+ res.locals.ssr = true; // mark response as SSR (enables text compression)
+ // send rendered page to user
+ res.send(data);
+ }
+ } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
+ // When this error occurs we can't fall back to CSR because the response has already been
+ // sent. These errors occur for various reasons in universal, not all of which are in our
+ // control to solve.
+ console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
+ } else {
+ console.warn('Error in server-side rendering (SSR)');
+ if (hasValue(err)) {
+ console.warn('Error details : ', err);
+ }
+ if (sendToUser) {
+ console.warn('Falling back to serving direct client-side rendering (CSR).');
+ clientSideRender(req, res);
+ }
+ }
+ });
+}
+
+/**
+ * Send back response to user to trigger direct client-side rendering (CSR)
+ * @param req current request
+ * @param res current response
+ */
+function clientSideRender(req, res) {
+ res.sendFile(indexHtml);
+}
+
+
+/*
+ * Adds a Cache-Control HTTP header to the response.
+ * The cache control value can be configured in the config.*.yml file
+ * Defaults to max-age=604,800 seconds (1 week)
+ */
+function addCacheControl(req, res, next) {
+ // instruct browser to revalidate
+ res.header('Cache-Control', environment.cache.control || 'max-age=604800');
+ next();
+}
+
+/*
+ * Initialize server-side caching of pages rendered via SSR.
+ */
+function initCache() {
+ if (botCacheEnabled()) {
+ // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
+ // See https://www.npmjs.com/package/lru-cache
+ // When enabled, each page defaults to expiring after 1 day
+ botCache = new LRU( {
+ max: environment.cache.serverSide.botCache.max,
+ ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
+ allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
+ });
+ }
+
+ if (anonymousCacheEnabled()) {
+ // NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
+ // may expire pages more frequently.
+ // When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
+ anonymousCache = new LRU( {
+ max: environment.cache.serverSide.anonymousCache.max,
+ ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
+ allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
});
}
}
-/*
- * Adds a cache control header to the response
- * The cache control value can be configured in the environments file and defaults to max-age=60
+/**
+ * Return whether bot-specific server side caching is enabled in configuration.
*/
-function cacheControl(req, res, next) {
- // instruct browser to revalidate
- res.header('Cache-Control', environment.cache.control || 'max-age=60');
- next();
+function botCacheEnabled(): boolean {
+ // Caching is only enabled if SSR is enabled AND
+ // "max" pages to cache is greater than zero
+ return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
+}
+
+/**
+ * Return whether anonymous user server side caching is enabled in configuration.
+ */
+function anonymousCacheEnabled(): boolean {
+ // Caching is only enabled if SSR is enabled AND
+ // "max" pages to cache is greater than zero
+ return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
+}
+
+/**
+ * Check if the currently requested page is in our server-side, in-memory cache.
+ * Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
+ */
+function cacheCheck(req, res, next) {
+ // Cached copy of page (if found)
+ let cachedCopy;
+
+ // If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
+ if (botCacheEnabled() && isbot(req.get('user-agent'))) {
+ cachedCopy = checkCacheForRequest('bot', botCache, req, res);
+ } else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
+ cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
+ }
+
+ // If cached copy exists, return it to the user.
+ if (cachedCopy && cachedCopy.page) {
+ if (cachedCopy.headers) {
+ Object.keys(cachedCopy.headers).forEach((header) => {
+ if (cachedCopy.headers[header]) {
+ if (environment.cache.serverSide.debug) {
+ console.log(`Restore cached ${header} header`);
+ }
+ res.setHeader(header, cachedCopy.headers[header]);
+ }
+ });
+ }
+ res.locals.ssr = true; // mark response as SSR-generated (enables text compression)
+ res.send(cachedCopy.page);
+
+ // Tell Express to skip all other handlers for this path
+ // This ensures we don't try to re-render the page since we've already returned the cached copy
+ next('router');
+ } else {
+ // If nothing found in cache, just continue with next handler
+ // (This should send the request on to the handler that rerenders the page via SSR
+ next();
+ }
+}
+
+/**
+ * Checks if the current request (i.e. page) is found in the given cache. If it is found,
+ * the cached copy is returned. When found, this method also triggers a re-render via
+ * SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy).
+ * @param cacheName name of cache (just useful for debug logging)
+ * @param cache LRU cache to check
+ * @param req current request to look for in the cache
+ * @param res current response
+ * @returns cached copy (if found) or undefined (if not found)
+ */
+function checkCacheForRequest(cacheName: string, cache: LRU, req, res): any {
+ // Get the cache key for this request
+ const key = getCacheKey(req);
+
+ // Check if this page is in our cache
+ let cachedCopy = cache.get(key);
+ if (cachedCopy) {
+ if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
+
+ // Check if cached copy is expired (If expired, the key will now be gone from cache)
+ // NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value.
+ if (!cache.has(key)) {
+ if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); }
+ // Update cached copy by rerendering server-side
+ // NOTE: In this scenario the currently cached copy will be returned to the current user.
+ // This re-render is peformed behind the scenes to update cached copy for next user.
+ serverSideRender(req, res, false);
+ }
+ } else {
+ if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
+ }
+
+ // return page from cache
+ return cachedCopy;
+}
+
+/**
+ * Create a cache key from the current request.
+ * The cache key is the URL path (NOTE: this key will also include any querystring params).
+ * E.g. "/home" or "/search?query=test"
+ * @param req current request
+ * @returns cache key to use for this page
+ */
+function getCacheKey(req): string {
+ // NOTE: this will return the URL path *without* any baseUrl
+ return req.url;
+}
+
+/**
+ * Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop
+ * If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired).
+ * (This minimizes the number of times we need to run SSR on the same page.)
+ * @param req current page request
+ * @param page page data to save to cache
+ */
+function saveToCache(req, page: any) {
+ // Only cache if no one is currently authenticated. This means ONLY public pages can be cached.
+ // NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation,
+ // the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info.
+ if (!isUserAuthenticated(req)) {
+ const key = getCacheKey(req);
+ // Avoid caching "/reload/[random]" paths (these are hard refreshes after logout)
+ if (key.startsWith('/reload')) { return; }
+ // Avoid caching not successful responses (status code different from 2XX status)
+ if (hasNotSucceeded(req.res.statusCode)) { return; }
+
+ // Retrieve response headers to save, if any
+ const headers = retrieveHeaders(req.res);
+ // If bot cache is enabled, save it to that cache if it doesn't exist or is expired
+ // (NOTE: has() will return false if page is expired in cache)
+ if (botCacheEnabled() && !botCache.has(key)) {
+ botCache.set(key, { page, headers });
+ if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); }
+ }
+
+ // If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired
+ if (anonymousCacheEnabled() && !anonymousCache.has(key)) {
+ anonymousCache.set(key, { page, headers });
+ if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); }
+ }
+ }
+}
+
+/**
+ * Check if status code is different from 2XX
+ * @param statusCode
+ */
+function hasNotSucceeded(statusCode) {
+ const rgx = new RegExp(/^20+/);
+ return !rgx.test(statusCode)
+}
+
+function retrieveHeaders(response) {
+ const headers = Object.create({});
+ if (Array.isArray(environment.cache.serverSide.headers) && environment.cache.serverSide.headers.length > 0) {
+ environment.cache.serverSide.headers.forEach((header) => {
+ if (response.hasHeader(header)) {
+ if (environment.cache.serverSide.debug) {
+ console.log(`Save ${header} header to cache`);
+ }
+ headers[header] = response.getHeader(header);
+ }
+ });
+ }
+
+ return headers;
+}
+/**
+ * Whether a user is authenticated or not
+ */
+function isUserAuthenticated(req): boolean {
+ // Check whether our DSpace authentication Cookie exists or not
+ return req.cookies[TOKENITEM];
}
/*
@@ -266,7 +525,7 @@ function serverStarted() {
* @param keys SSL credentials
*/
function createHttpsServer(keys) {
- https.createServer({
+ createServer({
key: keys.serviceKey,
cert: keys.certificate
}, app).listen(environment.ui.port, environment.ui.host, () => {
@@ -320,7 +579,7 @@ function start() {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation]
- pem.createCertificate({
+ createCertificate({
days: 1,
selfSigned: true
}, (error, keys) => {
diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts
index e64b0d170a..6f6de6cb26 100644
--- a/src/app/access-control/access-control-routing.module.ts
+++ b/src/app/access-control/access-control-routing.module.ts
@@ -6,8 +6,13 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
import { GROUP_EDIT_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard';
-import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
-import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
+import {
+ GroupAdministratorGuard
+} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
+import {
+ SiteAdministratorGuard
+} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
+import { BulkAccessComponent } from './bulk-access/bulk-access.component';
@NgModule({
imports: [
@@ -47,7 +52,16 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu
},
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
canActivate: [GroupPageGuard]
- }
+ },
+ {
+ path: 'bulk-access',
+ component: BulkAccessComponent,
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver
+ },
+ data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
+ canActivate: [SiteAdministratorGuard]
+ },
])
]
})
diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts
index 891238bbed..3dc4b6cedc 100644
--- a/src/app/access-control/access-control.module.ts
+++ b/src/app/access-control/access-control.module.ts
@@ -10,6 +10,22 @@ import { MembersListComponent } from './group-registry/group-form/members-list/m
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module';
+import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
+import { AbstractControl } from '@angular/forms';
+import { BulkAccessComponent } from './bulk-access/bulk-access.component';
+import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
+import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
+import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
+import { SearchModule } from '../shared/search/search.module';
+import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';
+
+/**
+ * Condition for displaying error messages on email form field
+ */
+export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
+ (control: AbstractControl, model: any, hasFocus: boolean) => {
+ return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
+ };
@NgModule({
imports: [
@@ -17,7 +33,13 @@ import { FormModule } from '../shared/form/form.module';
SharedModule,
RouterModule,
AccessControlRoutingModule,
- FormModule
+ FormModule,
+ NgbAccordionModule,
+ SearchModule,
+ AccessControlFormModule,
+ ],
+ exports: [
+ MembersListComponent,
],
declarations: [
EPeopleRegistryComponent,
@@ -25,7 +47,16 @@ import { FormModule } from '../shared/form/form.module';
GroupsRegistryComponent,
GroupFormComponent,
SubgroupsListComponent,
- MembersListComponent
+ MembersListComponent,
+ BulkAccessComponent,
+ BulkAccessBrowseComponent,
+ BulkAccessSettingsComponent,
+ ],
+ providers: [
+ {
+ provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
+ useValue: ValidateEmailErrorStateMatcher
+ },
]
})
/**
diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html
new file mode 100644
index 0000000000..c716aedb8b
--- /dev/null
+++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html
@@ -0,0 +1,67 @@
+
+
+
+