mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge remote-tracking branch 'dspaceMain/main' into w2p-111326_fix-status-code-object-not-found-7.4
This commit is contained in:
@@ -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.
|
|
@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
|
|||||||
|
|
||||||
[*.ts]
|
[*.ts]
|
||||||
quote_type = single
|
quote_type = single
|
||||||
|
|
||||||
|
[*.json5]
|
||||||
|
ij_json_keep_blank_lines_in_code = 3
|
||||||
|
109
.eslintrc.json
109
.eslintrc.json
@@ -6,7 +6,12 @@
|
|||||||
"eslint-plugin-import",
|
"eslint-plugin-import",
|
||||||
"eslint-plugin-jsdoc",
|
"eslint-plugin-jsdoc",
|
||||||
"eslint-plugin-deprecation",
|
"eslint-plugin-deprecation",
|
||||||
"eslint-plugin-unused-imports"
|
"unused-imports",
|
||||||
|
"eslint-plugin-lodash",
|
||||||
|
"eslint-plugin-jsonc",
|
||||||
|
"eslint-plugin-rxjs",
|
||||||
|
"eslint-plugin-simple-import-sort",
|
||||||
|
"eslint-plugin-import-newlines"
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
@@ -25,17 +30,29 @@
|
|||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
"plugin:@angular-eslint/recommended",
|
"plugin:@angular-eslint/recommended",
|
||||||
"plugin:@angular-eslint/template/process-inline-templates"
|
"plugin:@angular-eslint/template/process-inline-templates",
|
||||||
|
"plugin:rxjs/recommended"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"SwitchCase": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
"max-classes-per-file": [
|
"max-classes-per-file": [
|
||||||
"error",
|
"error",
|
||||||
1
|
1
|
||||||
],
|
],
|
||||||
"comma-dangle": [
|
"comma-dangle": [
|
||||||
"off",
|
"error",
|
||||||
"always-multiline"
|
"always-multiline"
|
||||||
],
|
],
|
||||||
|
"object-curly-spacing": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
"eol-last": [
|
"eol-last": [
|
||||||
"error",
|
"error",
|
||||||
"always"
|
"always"
|
||||||
@@ -102,15 +119,13 @@
|
|||||||
"allowTernary": true
|
"allowTernary": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint)
|
"prefer-const": "error",
|
||||||
|
"no-case-declarations": "error",
|
||||||
|
"no-extra-boolean-cast": "error",
|
||||||
"prefer-spread": "off",
|
"prefer-spread": "off",
|
||||||
"no-underscore-dangle": "off",
|
"no-underscore-dangle": "off",
|
||||||
|
|
||||||
// todo: disabled rules from eslint:recommended, consider re-enabling & fixing
|
|
||||||
"no-prototype-builtins": "off",
|
"no-prototype-builtins": "off",
|
||||||
"no-useless-escape": "off",
|
"no-useless-escape": "off",
|
||||||
"no-case-declarations": "off",
|
|
||||||
"no-extra-boolean-cast": "off",
|
|
||||||
|
|
||||||
"@angular-eslint/directive-selector": [
|
"@angular-eslint/directive-selector": [
|
||||||
"error",
|
"error",
|
||||||
@@ -181,7 +196,7 @@
|
|||||||
],
|
],
|
||||||
"@typescript-eslint/type-annotation-spacing": "error",
|
"@typescript-eslint/type-annotation-spacing": "error",
|
||||||
"@typescript-eslint/unified-signatures": "error",
|
"@typescript-eslint/unified-signatures": "error",
|
||||||
"@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable
|
"@typescript-eslint/ban-types": "error",
|
||||||
"@typescript-eslint/no-floating-promises": "warn",
|
"@typescript-eslint/no-floating-promises": "warn",
|
||||||
"@typescript-eslint/no-misused-promises": "warn",
|
"@typescript-eslint/no-misused-promises": "warn",
|
||||||
"@typescript-eslint/restrict-plus-operands": "warn",
|
"@typescript-eslint/restrict-plus-operands": "warn",
|
||||||
@@ -201,8 +216,45 @@
|
|||||||
|
|
||||||
"deprecation/deprecation": "warn",
|
"deprecation/deprecation": "warn",
|
||||||
|
|
||||||
|
"simple-import-sort/imports": "error",
|
||||||
|
"simple-import-sort/exports": "error",
|
||||||
"import/order": "off",
|
"import/order": "off",
|
||||||
"import/no-deprecated": "warn"
|
"import/first": "error",
|
||||||
|
"import/newline-after-import": "error",
|
||||||
|
"import/no-duplicates": "error",
|
||||||
|
"import/no-deprecated": "warn",
|
||||||
|
"import/no-namespace": "error",
|
||||||
|
"import-newlines/enforce": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"items": 1,
|
||||||
|
"semi": true,
|
||||||
|
"forceSingleLine": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"lodash/import-scope": [
|
||||||
|
"error",
|
||||||
|
"method"
|
||||||
|
],
|
||||||
|
|
||||||
|
"rxjs/no-nested-subscribe": "off" // todo: go over _all_ cases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.spec.ts"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": [
|
||||||
|
"./tsconfig.json",
|
||||||
|
"./cypress/tsconfig.json"
|
||||||
|
],
|
||||||
|
"createDefaultProgram": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"prefer-const": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,11 +263,42 @@
|
|||||||
],
|
],
|
||||||
"extends": [
|
"extends": [
|
||||||
"plugin:@angular-eslint/template/recommended"
|
"plugin:@angular-eslint/template/recommended"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.json5"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"plugin:jsonc/recommended-with-jsonc"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
// todo: re-enable & fix errors
|
"no-irregular-whitespace": "error",
|
||||||
"@angular-eslint/template/no-negated-async": "off",
|
"no-trailing-spaces": "error",
|
||||||
"@angular-eslint/template/eqeqeq": "off"
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -1,26 +0,0 @@
|
|||||||
# This workflow runs whenever a new pull request is created
|
|
||||||
# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs).
|
|
||||||
# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818
|
|
||||||
name: Pull Request opened
|
|
||||||
|
|
||||||
# Only run for newly opened PRs against the "main" branch
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened]
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
automation:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
|
|
||||||
# See https://github.com/marketplace/actions/pull-request-assigner
|
|
||||||
- name: Assign PR to creator
|
|
||||||
uses: thomaseizinger/assign-pr-creator-action@v1.0.0
|
|
||||||
# Note, this authentication token is created automatically
|
|
||||||
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ignore errors. It is possible the PR was created by someone who cannot be assigned
|
|
||||||
continue-on-error: true
|
|
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -1,7 +1,7 @@
|
|||||||
## References
|
## References
|
||||||
_Add references/links to any related issues or PRs. These may include:_
|
_Add references/links to any related issues or PRs. These may include:_
|
||||||
* Fixes #[issue-number]
|
* Fixes #`issue-number` (if this fixes an issue ticket)
|
||||||
* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this)
|
* Requires DSpace/DSpace#`pr-number` (if a REST API PR is required to test this)
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
Short summary of changes (1-2 sentences).
|
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!_
|
_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 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 passes [ESLint](https://eslint.org/) validation using `yarn lint`
|
||||||
- [ ] My PR doesn't introduce circular dependencies
|
- [ ] 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 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).
|
- [ ] 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).
|
||||||
|
90
.github/workflows/build.yml
vendored
90
.github/workflows/build.yml
vendored
@@ -6,34 +6,50 @@ name: Build
|
|||||||
# Run this Build for all pushes / PRs to current branch
|
# Run this Build for all pushes / PRs to current branch
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # to fetch code (actions/checkout)
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
# The ci step will test the dspace-angular code against DSpace REST.
|
# 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.
|
# 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_PORT: 8080
|
||||||
DSPACE_REST_NAMESPACE: '/server'
|
DSPACE_REST_NAMESPACE: '/server'
|
||||||
DSPACE_REST_SSL: false
|
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
|
# When Chrome version is specified, we pin to a specific version of Chrome
|
||||||
# Comment this out to use the latest release
|
# Comment this out to use the latest release
|
||||||
#CHROME_VERSION: "90.0.4430.212-1"
|
#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'
|
||||||
|
# Project name to use when running docker-compose prior to e2e tests
|
||||||
|
COMPOSE_PROJECT_NAME: 'ci'
|
||||||
strategy:
|
strategy:
|
||||||
# Create a matrix of Node versions to test against (in parallel)
|
# Create a matrix of Node versions to test against (in parallel)
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [14.x, 16.x]
|
node-version: [16.x, 18.x]
|
||||||
# Do NOT exit immediately if one matrix job fails
|
# Do NOT exit immediately if one matrix job fails
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# These are the actual CI steps to perform per job
|
# These are the actual CI steps to perform per job
|
||||||
steps:
|
steps:
|
||||||
# https://github.com/actions/checkout
|
# https://github.com/actions/checkout
|
||||||
- name: Checkout codebase
|
- name: Checkout codebase
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# https://github.com/actions/setup-node
|
# https://github.com/actions/setup-node
|
||||||
- name: Install Node.js ${{ matrix.node-version }}
|
- name: Install Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
@@ -56,9 +72,9 @@ jobs:
|
|||||||
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
|
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
|
||||||
- name: Get Yarn cache directory
|
- name: Get Yarn cache directory
|
||||||
id: yarn-cache-dir-path
|
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
|
- name: Cache Yarn dependencies
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
# Cache entire Yarn cache directory (see previous step)
|
# Cache entire Yarn cache directory (see previous step)
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||||
@@ -81,12 +97,16 @@ jobs:
|
|||||||
- name: Run specs (unit tests)
|
- name: Run specs (unit tests)
|
||||||
run: yarn run test:headless
|
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
|
# 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)
|
- name: Upload code coverage report to Artifact
|
||||||
# https://github.com/codecov/codecov-action
|
uses: actions/upload-artifact@v3
|
||||||
- name: Upload coverage to Codecov.io
|
if: matrix.node-version == '18.x'
|
||||||
uses: codecov/codecov-action@v2
|
with:
|
||||||
if: matrix.node-version == '16.x'
|
name: dspace-angular coverage report
|
||||||
|
path: 'coverage/dspace-angular/lcov.info'
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# Using docker-compose start backend using CI configuration
|
# Using docker-compose start backend using CI configuration
|
||||||
# and load assetstore from a cached copy
|
# and load assetstore from a cached copy
|
||||||
@@ -100,23 +120,22 @@ jobs:
|
|||||||
# https://github.com/cypress-io/github-action
|
# https://github.com/cypress-io/github-action
|
||||||
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
||||||
- name: Run e2e tests (integration tests)
|
- name: Run e2e tests (integration tests)
|
||||||
uses: cypress-io/github-action@v2
|
uses: cypress-io/github-action@v6
|
||||||
with:
|
with:
|
||||||
# Run tests in Chrome, headless mode
|
# Run tests in Chrome, headless mode (default)
|
||||||
browser: chrome
|
browser: chrome
|
||||||
headless: true
|
|
||||||
# Start app before running tests (will be stopped automatically after tests finish)
|
# Start app before running tests (will be stopped automatically after tests finish)
|
||||||
start: yarn run serve:ssr
|
start: yarn run serve:ssr
|
||||||
# Wait for backend & frontend to be available
|
# Wait for backend & frontend to be available
|
||||||
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
|
# 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 for 2 mins max for everything to respond
|
||||||
wait-on-timeout: 120
|
wait-on-timeout: 120
|
||||||
|
|
||||||
# Cypress always creates a video of all e2e tests (whether they succeeded or failed)
|
# Cypress always creates a video of all e2e tests (whether they succeeded or failed)
|
||||||
# Save those in an Artifact
|
# Save those in an Artifact
|
||||||
- name: Upload e2e test videos to Artifacts
|
- name: Upload e2e test videos to Artifacts
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: e2e-test-videos
|
name: e2e-test-videos
|
||||||
@@ -125,7 +144,7 @@ jobs:
|
|||||||
# If e2e tests fail, Cypress creates a screenshot of what happened
|
# If e2e tests fail, Cypress creates a screenshot of what happened
|
||||||
# Save those in an Artifact
|
# Save those in an Artifact
|
||||||
- name: Upload e2e test failure screenshots to Artifacts
|
- name: Upload e2e test failure screenshots to Artifacts
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-test-screenshots
|
name: e2e-test-screenshots
|
||||||
@@ -144,7 +163,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
nohup yarn run serve:ssr &
|
nohup yarn run serve:ssr &
|
||||||
printf 'Waiting for app to start'
|
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 '.'
|
printf '.'
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
@@ -155,7 +174,7 @@ jobs:
|
|||||||
# This step also prints entire HTML of homepage for easier debugging if grep fails.
|
# This step also prints entire HTML of homepage for easier debugging if grep fails.
|
||||||
- name: Verify SSR (server-side rendering)
|
- name: Verify SSR (server-side rendering)
|
||||||
run: |
|
run: |
|
||||||
result=$(wget -O- -q http://localhost:4000/home)
|
result=$(wget -O- -q http://127.0.0.1:4000/home)
|
||||||
echo "$result"
|
echo "$result"
|
||||||
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace
|
||||||
|
|
||||||
@@ -164,3 +183,36 @@ jobs:
|
|||||||
|
|
||||||
- name: Shutdown Docker containers
|
- name: Shutdown Docker containers
|
||||||
run: docker-compose -f ./docker/docker-compose-ci.yml down
|
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@v4
|
||||||
|
|
||||||
|
# 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.3.0
|
||||||
|
with:
|
||||||
|
action: codecov/codecov-action@v3
|
||||||
|
# Ensure codecov-action throws an error when it fails to upload
|
||||||
|
# This allows us to auto-restart the action if an error is thrown
|
||||||
|
with: |
|
||||||
|
fail_ci_if_error: true
|
||||||
|
# Try re-running action 5 times max
|
||||||
|
attempt_limit: 5
|
||||||
|
# Run again in 30 seconds
|
||||||
|
attempt_delay: 30000
|
||||||
|
53
.github/workflows/codescan.yml
vendored
Normal file
53
.github/workflows/codescan.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 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 or maintenance branches. Also run once a week.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dspace-**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dspace-**'
|
||||||
|
# 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@v4
|
||||||
|
|
||||||
|
# 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
|
110
.github/workflows/docker.yml
vendored
110
.github/workflows/docker.yml
vendored
@@ -3,6 +3,9 @@ name: Docker images
|
|||||||
|
|
||||||
# Run this Build for all pushes to 'main' or maintenance branches, or tagged releases.
|
# Run this Build for all pushes to 'main' or maintenance branches, or tagged releases.
|
||||||
# Also run for PRs to ensure PR doesn't break Docker build process
|
# Also run for PRs to ensure PR doesn't break Docker build process
|
||||||
|
# NOTE: uses "reusable-docker-build.yml" in DSpace/DSpace to actually build each of the Docker images
|
||||||
|
# https://github.com/DSpace/DSpace/blob/main/.github/workflows/reusable-docker-build.yml
|
||||||
|
#
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -12,76 +15,45 @@ on:
|
|||||||
- 'dspace-**'
|
- 'dspace-**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # to fetch code (actions/checkout)
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
#############################################################
|
||||||
|
# Build/Push the 'dspace/dspace-angular' image
|
||||||
|
#############################################################
|
||||||
|
dspace-angular:
|
||||||
# Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
|
# Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
|
||||||
if: github.repository == 'dspace/dspace-angular'
|
if: github.repository == 'dspace/dspace-angular'
|
||||||
runs-on: ubuntu-latest
|
# Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image
|
||||||
env:
|
uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main
|
||||||
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
|
with:
|
||||||
# For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image.
|
build_id: dspace-angular
|
||||||
# For a new commit on other branches, use the branch name as the tag for Docker image.
|
image_name: dspace/dspace-angular
|
||||||
# For a new tag, copy that tag name as the tag for Docker image.
|
dockerfile_path: ./Dockerfile
|
||||||
IMAGE_TAGS: |
|
secrets:
|
||||||
type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }}
|
DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||||
type=ref,event=tag
|
|
||||||
# Define default tag "flavor" for docker/metadata-action per
|
|
||||||
# https://github.com/docker/metadata-action#flavor-input
|
|
||||||
# We turn off 'latest' tag by default.
|
|
||||||
TAGS_FLAVOR: |
|
|
||||||
latest=false
|
|
||||||
# Architectures / Platforms for which we will build Docker images
|
|
||||||
# If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work.
|
|
||||||
# If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64.
|
|
||||||
PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }}
|
|
||||||
|
|
||||||
steps:
|
#############################################################
|
||||||
# https://github.com/actions/checkout
|
# Build/Push the 'dspace/dspace-angular' image ('-dist' tag)
|
||||||
- name: Checkout codebase
|
#############################################################
|
||||||
uses: actions/checkout@v2
|
dspace-angular-dist:
|
||||||
|
# Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
|
||||||
# https://github.com/docker/setup-buildx-action
|
if: github.repository == 'dspace/dspace-angular'
|
||||||
- name: Setup Docker Buildx
|
# Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main
|
||||||
|
with:
|
||||||
# https://github.com/docker/setup-qemu-action
|
build_id: dspace-angular-dist
|
||||||
- name: Set up QEMU emulation to build for multiple architectures
|
image_name: dspace/dspace-angular
|
||||||
uses: docker/setup-qemu-action@v2
|
dockerfile_path: ./Dockerfile.dist
|
||||||
|
# As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
|
||||||
# https://github.com/docker/login-action
|
# tagging logic as the primary 'dspace/dspace-angular' image above.
|
||||||
- name: Login to DockerHub
|
tags_flavor: suffix=-dist
|
||||||
# Only login if not a PR, as PRs only trigger a Docker build and not a push
|
secrets:
|
||||||
if: github.event_name != 'pull_request'
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
uses: docker/login-action@v1
|
DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||||
with:
|
# Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
# these sites as specified in reusable-docker-build.xml
|
||||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }}
|
||||||
|
REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }}
|
||||||
###############################################
|
|
||||||
# Build/Push the 'dspace/dspace-angular' image
|
|
||||||
###############################################
|
|
||||||
# https://github.com/docker/metadata-action
|
|
||||||
# Get Metadata for docker_build step below
|
|
||||||
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image
|
|
||||||
id: meta_build
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
with:
|
|
||||||
images: dspace/dspace-angular
|
|
||||||
tags: ${{ env.IMAGE_TAGS }}
|
|
||||||
flavor: ${{ env.TAGS_FLAVOR }}
|
|
||||||
|
|
||||||
# https://github.com/docker/build-push-action
|
|
||||||
- name: Build and push 'dspace-angular' image
|
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
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.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta_build.outputs.labels }}
|
|
17
.github/workflows/issue_opened.yml
vendored
17
.github/workflows/issue_opened.yml
vendored
@@ -5,25 +5,22 @@ on:
|
|||||||
issues:
|
issues:
|
||||||
types: [opened]
|
types: [opened]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
automation:
|
automation:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Add the new issue to a project board, if it needs triage
|
# Add the new issue to a project board, if it needs triage
|
||||||
# See https://github.com/marketplace/actions/create-project-card-action
|
# See https://github.com/actions/add-to-project
|
||||||
- name: Add issue to project board
|
- name: Add issue to triage board
|
||||||
# Only add to project board if issue is flagged as "needs triage" or has no labels
|
# 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
|
# 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) == '')
|
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.
|
# 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
|
# 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)
|
# This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific)
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }}
|
github-token: ${{ secrets.TRIAGE_PROJECT_TOKEN }}
|
||||||
PROJECT: DSpace Backlog
|
project-url: https://github.com/orgs/DSpace/projects/24
|
||||||
COLUMN: Triage
|
|
||||||
CHECK_ORG_PROJECT: true
|
|
||||||
# Ignore errors
|
|
||||||
continue-on-error: true
|
|
||||||
|
30
.github/workflows/label_merge_conflicts.yml
vendored
30
.github/workflows/label_merge_conflicts.yml
vendored
@@ -1,25 +1,39 @@
|
|||||||
# This workflow checks open PRs for merge conflicts and labels them when conflicts are found
|
# This workflow checks open PRs for merge conflicts and labels them when conflicts are found
|
||||||
name: Check for merge conflicts
|
name: Check for merge conflicts
|
||||||
|
|
||||||
# Run whenever the "main" branch is updated
|
# Run this for all pushes (i.e. merges) to 'main' or maintenance branches
|
||||||
# NOTE: This means merge conflicts are only checked for when a PR is merged to main.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- 'dspace-**'
|
||||||
|
# So that the `conflict_label_name` is removed if conflicts are resolved,
|
||||||
|
# we allow this to run for `pull_request_target` so that github secrets are available.
|
||||||
|
pull_request_target:
|
||||||
|
types: [ synchronize ]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
triage:
|
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
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
steps:
|
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
|
- name: Auto-label PRs with merge conflicts
|
||||||
uses: mschilde/auto-label-merge-conflicts@v2.0
|
uses: prince-chrismc/label-merge-conflicts-action@v3
|
||||||
|
# Ignore any failures -- may occur (randomly?) for older, outdated PRs.
|
||||||
|
continue-on-error: true
|
||||||
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
|
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
|
||||||
# Note, the authentication token is created automatically
|
# Note, the authentication token is created automatically
|
||||||
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
|
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
|
||||||
with:
|
with:
|
||||||
CONFLICT_LABEL_NAME: 'merge conflict'
|
conflict_label_name: 'merge conflict'
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Ignore errors
|
conflict_comment: |
|
||||||
continue-on-error: true
|
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!
|
46
.github/workflows/port_merged_pull_request.yml
vendored
Normal file
46
.github/workflows/port_merged_pull_request.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# This workflow will attempt to port a merged pull request to
|
||||||
|
# the branch specified in a "port to" label (if exists)
|
||||||
|
name: Port merged Pull Request
|
||||||
|
|
||||||
|
# Only run for merged PRs against the "main" or maintenance branches
|
||||||
|
# We allow this to run for `pull_request_target` so that github secrets are available
|
||||||
|
# (This is required when the PR comes from a forked repo)
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [ closed ]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dspace-**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # so action can add comments
|
||||||
|
pull-requests: write # so action can create pull requests
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
port_pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Don't run on closed *unmerged* pull requests
|
||||||
|
if: github.event.pull_request.merged
|
||||||
|
steps:
|
||||||
|
# Checkout code
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# Port PR to other branch (ONLY if labeled with "port to")
|
||||||
|
# See https://github.com/korthout/backport-action
|
||||||
|
- name: Create backport pull requests
|
||||||
|
uses: korthout/backport-action@v2
|
||||||
|
with:
|
||||||
|
# Trigger based on a "port to [branch]" label on PR
|
||||||
|
# (This label must specify the branch name to port to)
|
||||||
|
label_pattern: '^port to ([^ ]+)$'
|
||||||
|
# Title to add to the (newly created) port PR
|
||||||
|
pull_title: '[Port ${target_branch}] ${pull_title}'
|
||||||
|
# Description to add to the (newly created) port PR
|
||||||
|
pull_description: 'Port of #${pull_number} by @${pull_author} to `${target_branch}`.'
|
||||||
|
# Copy all labels from original PR to (newly created) port PR
|
||||||
|
# NOTE: The labels matching 'label_pattern' are automatically excluded
|
||||||
|
copy_labels_pattern: '.*'
|
||||||
|
# Skip any merge commits in the ported PR. This means only non-merge commits are cherry-picked to the new PR
|
||||||
|
merge_commits: 'skip'
|
||||||
|
# Use a personal access token (PAT) to create PR as 'dspace-bot' user.
|
||||||
|
# A PAT is required in order for the new PR to trigger its own actions (for CI checks)
|
||||||
|
github_token: ${{ secrets.PR_PORT_TOKEN }}
|
24
.github/workflows/pull_request_opened.yml
vendored
Normal file
24
.github/workflows/pull_request_opened.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# This workflow runs whenever a new pull request is created
|
||||||
|
name: Pull Request opened
|
||||||
|
|
||||||
|
# Only run for newly opened PRs against the "main" or maintenance branches
|
||||||
|
# We allow this to run for `pull_request_target` so that github secrets are available
|
||||||
|
# (This is required to assign a PR back to the creator when the PR comes from a forked repo)
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [ opened ]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dspace-**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
automation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
|
||||||
|
# See https://github.com/toshimaru/auto-author-assign
|
||||||
|
- name: Assign PR to creator
|
||||||
|
uses: toshimaru/auto-author-assign@v2.0.1
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ package-lock.json
|
|||||||
/nbproject/
|
/nbproject/
|
||||||
|
|
||||||
junit.xml
|
junit.xml
|
||||||
|
|
||||||
|
/src/mirador-viewer/config.local.js
|
||||||
|
46
CONTRIBUTING.md
Normal file
46
CONTRIBUTING.md
Normal file
@@ -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.
|
15
Dockerfile
15
Dockerfile
@@ -1,7 +1,12 @@
|
|||||||
# This image will be published as dspace/dspace-angular
|
# This image will be published as dspace/dspace-angular
|
||||||
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
|
# 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
|
WORKDIR /app
|
||||||
ADD . /app/
|
ADD . /app/
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
@@ -10,8 +15,14 @@ EXPOSE 4000
|
|||||||
# See, for example https://github.com/yarnpkg/yarn/issues/5540
|
# See, for example https://github.com/yarnpkg/yarn/issues/5540
|
||||||
RUN yarn install --network-timeout 300000
|
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).
|
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
|
||||||
# Listen / accept connections from all IP addresses.
|
# Listen / accept connections from all IP addresses.
|
||||||
# NOTE: At this time it is only possible to run Docker container in Production mode
|
# 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
|
CMD yarn serve --host 0.0.0.0
|
||||||
|
31
Dockerfile.dist
Normal file
31
Dockerfile.dist
Normal file
@@ -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:latest-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
|
19
README.md
19
README.md
@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
|
|||||||
Quick start
|
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
|
```bash
|
||||||
# clone the repo
|
# clone the repo
|
||||||
@@ -90,7 +90,7 @@ Requirements
|
|||||||
------------
|
------------
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com)
|
- [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.
|
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.
|
||||||
|
|
||||||
@@ -157,8 +157,8 @@ DSPACE_UI_SSL => DSPACE_SSL
|
|||||||
|
|
||||||
The same settings can also be overwritten by setting system environment variables instead, E.g.:
|
The same settings can also be overwritten by setting system environment variables instead, E.g.:
|
||||||
```bash
|
```bash
|
||||||
export DSPACE_HOST=api7.dspace.org
|
export DSPACE_HOST=demo.dspace.org
|
||||||
export DSPACE_UI_PORT=4200
|
export DSPACE_UI_PORT=4000
|
||||||
```
|
```
|
||||||
|
|
||||||
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`**
|
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`**
|
||||||
@@ -288,7 +288,7 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con
|
|||||||
The test files can be found in the `./cypress/integration/` folder.
|
The test files can be found in the `./cypress/integration/` folder.
|
||||||
|
|
||||||
Before you can run e2e tests, two things are REQUIRED:
|
Before you can run e2e tests, two things are REQUIRED:
|
||||||
1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time.
|
1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo/sandbox REST API (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/), as those sites may have content added/removed at any time.
|
||||||
* After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend.
|
* After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend.
|
||||||
* If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example:
|
* If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example:
|
||||||
```
|
```
|
||||||
@@ -351,7 +351,7 @@ Documentation
|
|||||||
|
|
||||||
Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/
|
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
|
### 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)
|
- [Sublime Text](http://www.sublimetext.com/3)
|
||||||
- [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation)
|
- [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
|
File Structure
|
||||||
--------------
|
--------------
|
||||||
@@ -413,8 +413,7 @@ dspace-angular
|
|||||||
│ ├── merge-i18n-files.ts *
|
│ ├── merge-i18n-files.ts *
|
||||||
│ ├── serve.ts *
|
│ ├── serve.ts *
|
||||||
│ ├── sync-i18n-files.ts *
|
│ ├── sync-i18n-files.ts *
|
||||||
│ ├── test-rest.ts *
|
│ └── test-rest.ts *
|
||||||
│ └── webpack.js *
|
|
||||||
├── src * The source of the application
|
├── src * The source of the application
|
||||||
│ ├── app * The source code of the application, subdivided by module/page.
|
│ ├── app * The source code of the application, subdivided by module/page.
|
||||||
│ ├── assets * Folder for static resources
|
│ ├── assets * Folder for static resources
|
||||||
|
18
angular.json
18
angular.json
@@ -25,12 +25,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"angular2-text-mask",
|
|
||||||
"cerialize",
|
"cerialize",
|
||||||
"core-js",
|
"core-js",
|
||||||
"lodash",
|
"lodash",
|
||||||
"jwt-decode",
|
"jwt-decode",
|
||||||
"url-parse",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"webfontloader",
|
"webfontloader",
|
||||||
"zone.js"
|
"zone.js"
|
||||||
@@ -268,16 +266,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": [
|
"lintFilePatterns": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.html"
|
"src/**/*.html",
|
||||||
|
"src/**/*.json5"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultProject": "dspace-angular",
|
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": false,
|
"analytics": false,
|
||||||
"defaultCollection": "@angular-eslint/schematics"
|
"schematicCollections": [
|
||||||
|
"@angular-eslint/schematics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schematics": {
|
||||||
|
"@angular-eslint/schematics:application": {
|
||||||
|
"setParserOptionsProject": true
|
||||||
|
},
|
||||||
|
"@angular-eslint/schematics:library": {
|
||||||
|
"setParserOptionsProject": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -22,7 +22,7 @@ ui:
|
|||||||
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
|
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
|
||||||
rest:
|
rest:
|
||||||
ssl: true
|
ssl: true
|
||||||
host: api7.dspace.org
|
host: sandbox.dspace.org
|
||||||
port: 443
|
port: 443
|
||||||
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
|
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
|
||||||
nameSpace: /server
|
nameSpace: /server
|
||||||
@@ -32,12 +32,60 @@ cache:
|
|||||||
# NOTE: how long should objects be cached for by default
|
# NOTE: how long should objects be cached for by default
|
||||||
msToLive:
|
msToLive:
|
||||||
default: 900000 # 15 minutes
|
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:
|
autoSync:
|
||||||
defaultTime: 0
|
defaultTime: 0
|
||||||
maxBufferSize: 100
|
maxBufferSize: 100
|
||||||
timePerMethod:
|
timePerMethod:
|
||||||
PATCH: 3 # time in seconds
|
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
|
# Authentication settings
|
||||||
auth:
|
auth:
|
||||||
@@ -55,6 +103,8 @@ auth:
|
|||||||
|
|
||||||
# Form settings
|
# Form settings
|
||||||
form:
|
form:
|
||||||
|
# Sets the spellcheck textarea attribute value
|
||||||
|
spellCheck: true
|
||||||
# NOTE: Map server-side validators to comparative Angular form validators
|
# NOTE: Map server-side validators to comparative Angular form validators
|
||||||
validatorMap:
|
validatorMap:
|
||||||
required: required
|
required: required
|
||||||
@@ -81,12 +131,16 @@ submission:
|
|||||||
# NOTE: after how many time (milliseconds) submission is saved automatically
|
# NOTE: after how many time (milliseconds) submission is saved automatically
|
||||||
# eg. timer: 5 * (1000 * 60); // 5 minutes
|
# eg. timer: 5 * (1000 * 60); // 5 minutes
|
||||||
timer: 0
|
timer: 0
|
||||||
|
# Always show the duplicate detection section if enabled, even if there are no potential duplicates detected
|
||||||
|
# (a message will be displayed to indicate no matches were found)
|
||||||
|
duplicateDetection:
|
||||||
|
alwaysShowSection: false
|
||||||
icons:
|
icons:
|
||||||
metadata:
|
metadata:
|
||||||
# NOTE: example of configuration
|
# NOTE: example of configuration
|
||||||
# # NOTE: metadata name
|
# # NOTE: metadata name
|
||||||
# - name: dc.author
|
# - name: dc.author
|
||||||
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
|
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
|
||||||
# style: fas fa-user
|
# style: fas fa-user
|
||||||
- name: dc.author
|
- name: dc.author
|
||||||
style: fas fa-user
|
style: fas fa-user
|
||||||
@@ -97,18 +151,40 @@ submission:
|
|||||||
confidence:
|
confidence:
|
||||||
# NOTE: example of configuration
|
# NOTE: example of configuration
|
||||||
# # NOTE: confidence value
|
# # NOTE: confidence value
|
||||||
# - name: dc.author
|
# - value: 600
|
||||||
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
|
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
|
||||||
# style: fa-user
|
# style: text-success
|
||||||
|
# icon: fa-circle-check
|
||||||
|
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
|
||||||
|
# configured to use a 'icon mode' display (mainly in edit-item page)
|
||||||
- value: 600
|
- value: 600
|
||||||
style: text-success
|
style: text-success
|
||||||
|
icon: fa-circle-check
|
||||||
- value: 500
|
- value: 500
|
||||||
style: text-info
|
style: text-info
|
||||||
|
icon: fa-gear
|
||||||
- value: 400
|
- value: 400
|
||||||
style: text-warning
|
style: text-warning
|
||||||
|
icon: fa-circle-question
|
||||||
|
- value: 300
|
||||||
|
style: text-muted
|
||||||
|
icon: fa-thumbs-down
|
||||||
|
- value: 200
|
||||||
|
style: text-muted
|
||||||
|
icon: fa-circle-exclamation
|
||||||
|
- value: 100
|
||||||
|
style: text-muted
|
||||||
|
icon: fa-circle-stop
|
||||||
|
- value: 0
|
||||||
|
style: text-muted
|
||||||
|
icon: fa-ban
|
||||||
|
- value: -1
|
||||||
|
style: text-muted
|
||||||
|
icon: fa-circle-xmark
|
||||||
# default configuration
|
# default configuration
|
||||||
- value: default
|
- value: default
|
||||||
style: text-muted
|
style: text-muted
|
||||||
|
icon: fa-circle-xmark
|
||||||
|
|
||||||
# Default Language in which the UI will be rendered if the user's browser language is not an active language
|
# Default Language in which the UI will be rendered if the user's browser language is not an active language
|
||||||
defaultLanguage: en
|
defaultLanguage: en
|
||||||
@@ -119,6 +195,9 @@ languages:
|
|||||||
- code: en
|
- code: en
|
||||||
label: English
|
label: English
|
||||||
active: true
|
active: true
|
||||||
|
- code: ca
|
||||||
|
label: Català
|
||||||
|
active: true
|
||||||
- code: cs
|
- code: cs
|
||||||
label: Čeština
|
label: Čeština
|
||||||
active: true
|
active: true
|
||||||
@@ -134,6 +213,9 @@ languages:
|
|||||||
- code: gd
|
- code: gd
|
||||||
label: Gàidhlig
|
label: Gàidhlig
|
||||||
active: true
|
active: true
|
||||||
|
- code: it
|
||||||
|
label: Italiano
|
||||||
|
active: true
|
||||||
- code: lv
|
- code: lv
|
||||||
label: Latviešu
|
label: Latviešu
|
||||||
active: true
|
active: true
|
||||||
@@ -143,12 +225,18 @@ languages:
|
|||||||
- code: nl
|
- code: nl
|
||||||
label: Nederlands
|
label: Nederlands
|
||||||
active: true
|
active: true
|
||||||
|
- code: pl
|
||||||
|
label: Polski
|
||||||
|
active: true
|
||||||
- code: pt-PT
|
- code: pt-PT
|
||||||
label: Português
|
label: Português
|
||||||
active: true
|
active: true
|
||||||
- code: pt-BR
|
- code: pt-BR
|
||||||
label: Português do Brasil
|
label: Português do Brasil
|
||||||
active: true
|
active: true
|
||||||
|
- code: sr-lat
|
||||||
|
label: Srpski (lat)
|
||||||
|
active: true
|
||||||
- code: fi
|
- code: fi
|
||||||
label: Suomi
|
label: Suomi
|
||||||
active: true
|
active: true
|
||||||
@@ -158,6 +246,9 @@ languages:
|
|||||||
- code: tr
|
- code: tr
|
||||||
label: Türkçe
|
label: Türkçe
|
||||||
active: true
|
active: true
|
||||||
|
- code: vi
|
||||||
|
label: Tiếng Việt
|
||||||
|
active: true
|
||||||
- code: kk
|
- code: kk
|
||||||
label: Қазақ
|
label: Қазақ
|
||||||
active: true
|
active: true
|
||||||
@@ -170,6 +261,13 @@ languages:
|
|||||||
- code: el
|
- code: el
|
||||||
label: Ελληνικά
|
label: Ελληνικά
|
||||||
active: true
|
active: true
|
||||||
|
- code: sr-cyr
|
||||||
|
label: Српски
|
||||||
|
active: true
|
||||||
|
- code: uk
|
||||||
|
label: Yкраї́нська
|
||||||
|
active: true
|
||||||
|
|
||||||
|
|
||||||
# Browse-By Pages
|
# Browse-By Pages
|
||||||
browseBy:
|
browseBy:
|
||||||
@@ -200,6 +298,8 @@ homePage:
|
|||||||
# No. of communities to list per page on the home page
|
# No. of communities to list per page on the home page
|
||||||
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
|
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
|
||||||
pageSize: 5
|
pageSize: 5
|
||||||
|
# Enable or disable the Discover filters on the homepage
|
||||||
|
showDiscoverFilters: false
|
||||||
|
|
||||||
# Item Config
|
# Item Config
|
||||||
item:
|
item:
|
||||||
@@ -207,9 +307,23 @@ item:
|
|||||||
undoTimeout: 10000 # 10 seconds
|
undoTimeout: 10000 # 10 seconds
|
||||||
# Show the item access status label in items lists
|
# Show the item access status label in items lists
|
||||||
showAccessStatuses: false
|
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
|
||||||
|
|
||||||
|
# Community Page Config
|
||||||
|
community:
|
||||||
|
# Search tab config
|
||||||
|
searchSection:
|
||||||
|
showSidebar: true
|
||||||
|
|
||||||
# Collection Page Config
|
# Collection Page Config
|
||||||
collection:
|
collection:
|
||||||
|
# Search tab config
|
||||||
|
searchSection:
|
||||||
|
showSidebar: true
|
||||||
edit:
|
edit:
|
||||||
undoTimeout: 10000 # 10 seconds
|
undoTimeout: 10000 # 10 seconds
|
||||||
|
|
||||||
@@ -221,33 +335,33 @@ themes:
|
|||||||
#
|
#
|
||||||
# # A theme with a handle property will match the community, collection or item with the given
|
# # A theme with a handle property will match the community, collection or item with the given
|
||||||
# # handle, and all collections and/or items within it
|
# # handle, and all collections and/or items within it
|
||||||
# - name: 'custom',
|
# - name: custom
|
||||||
# handle: '10673/1233'
|
# handle: 10673/1233
|
||||||
#
|
#
|
||||||
# # A theme with a regex property will match the route using a regular expression. If it
|
# # A theme with a regex property will match the route using a regular expression. If it
|
||||||
# # matches the route for a community or collection it will also apply to all collections
|
# # matches the route for a community or collection it will also apply to all collections
|
||||||
# # and/or items within it
|
# # and/or items within it
|
||||||
# - name: 'custom',
|
# - name: custom
|
||||||
# regex: 'collections\/e8043bc2.*'
|
# regex: collections\/e8043bc2.*
|
||||||
#
|
#
|
||||||
# # A theme with a uuid property will match the community, collection or item with the given
|
# # A theme with a uuid property will match the community, collection or item with the given
|
||||||
# # ID, and all collections and/or items within it
|
# # ID, and all collections and/or items within it
|
||||||
# - name: 'custom',
|
# - name: custom
|
||||||
# uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
|
# uuid: 0958c910-2037-42a9-81c7-dca80e3892b4
|
||||||
#
|
#
|
||||||
# # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
|
# # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
|
||||||
# # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
|
# # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
|
||||||
# - name: 'custom-A',
|
# - name: custom-A
|
||||||
# extends: 'custom-B',
|
# extends: custom-B
|
||||||
# # Any of the matching properties above can be used
|
# # Any of the matching properties above can be used
|
||||||
# handle: '10673/34'
|
# handle: 10673/34
|
||||||
#
|
#
|
||||||
# - name: 'custom-B',
|
# - name: custom-B
|
||||||
# extends: 'custom',
|
# extends: custom
|
||||||
# handle: '10673/12'
|
# handle: 10673/12
|
||||||
#
|
#
|
||||||
# # A theme with only a name will match every route
|
# # A theme with only a name will match every route
|
||||||
# name: 'custom'
|
# name: custom
|
||||||
#
|
#
|
||||||
# # This theme will use the default bootstrap styling for DSpace components
|
# # This theme will use the default bootstrap styling for DSpace components
|
||||||
# - name: BASE_THEME_NAME
|
# - name: BASE_THEME_NAME
|
||||||
@@ -295,4 +409,89 @@ info:
|
|||||||
# display in supported metadata fields. By default, only dc.description.abstract is supported.
|
# display in supported metadata fields. By default, only dc.description.abstract is supported.
|
||||||
markdown:
|
markdown:
|
||||||
enabled: false
|
enabled: false
|
||||||
mathjax: false
|
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'
|
||||||
|
|
||||||
|
# Example of fallback collection for suggestions import
|
||||||
|
# suggestion:
|
||||||
|
# - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af
|
||||||
|
# source: "openaire"
|
||||||
|
|
||||||
|
|
||||||
|
# Search settings
|
||||||
|
search:
|
||||||
|
# Settings to enable/disable or configure advanced search filters.
|
||||||
|
advancedFilters:
|
||||||
|
enabled: false
|
||||||
|
# List of filters to enable in "Advanced Search" dropdown
|
||||||
|
filter: [ 'title', 'author', 'subject', 'entityType' ]
|
||||||
|
|
||||||
|
|
||||||
|
# Notify metrics
|
||||||
|
# Configuration for Notify Admin Dashboard for metrics visualization
|
||||||
|
notifyMetrics:
|
||||||
|
# Configuration for received messages
|
||||||
|
- title: 'admin-notify-dashboard.received-ldn'
|
||||||
|
boxes:
|
||||||
|
- color: '#B8DAFF'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.incoming.accepted'
|
||||||
|
config: 'NOTIFY.incoming.accepted'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description'
|
||||||
|
- color: '#D4EDDA'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.incoming.processed'
|
||||||
|
config: 'NOTIFY.incoming.processed'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description'
|
||||||
|
- color: '#FDBBC7'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.incoming.failure'
|
||||||
|
config: 'NOTIFY.incoming.failure'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description'
|
||||||
|
- color: '#FDBBC7'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted'
|
||||||
|
config: 'NOTIFY.incoming.untrusted'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description'
|
||||||
|
- color: '#43515F'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems'
|
||||||
|
textColor: '#fff'
|
||||||
|
config: 'NOTIFY.incoming.involvedItems'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description'
|
||||||
|
# Configuration for outgoing messages
|
||||||
|
- title: 'admin-notify-dashboard.generated-ldn'
|
||||||
|
boxes:
|
||||||
|
- color: '#B8DAFF'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued'
|
||||||
|
config: 'NOTIFY.outgoing.queued'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description'
|
||||||
|
- color: '#FDEEBB'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry'
|
||||||
|
config: 'NOTIFY.outgoing.queued_for_retry'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description'
|
||||||
|
- color: '#FDBBC7'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.outgoing.failure'
|
||||||
|
config: 'NOTIFY.outgoing.failure'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description'
|
||||||
|
- color: '#43515F'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems'
|
||||||
|
textColor: '#fff'
|
||||||
|
config: 'NOTIFY.outgoing.involvedItems'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description'
|
||||||
|
- color: '#D4EDDA'
|
||||||
|
title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered'
|
||||||
|
config: 'NOTIFY.outgoing.delivered'
|
||||||
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
rest:
|
rest:
|
||||||
ssl: true
|
ssl: true
|
||||||
host: api7.dspace.org
|
host: sandbox.dspace.org
|
||||||
port: 443
|
port: 443
|
||||||
nameSpace: /server
|
nameSpace: /server
|
||||||
|
47
cypress.config.ts
Normal file
47
cypress.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
videosFolder: 'cypress/videos',
|
||||||
|
screenshotsFolder: 'cypress/screenshots',
|
||||||
|
fixturesFolder: 'cypress/fixtures',
|
||||||
|
retries: {
|
||||||
|
runMode: 2,
|
||||||
|
openMode: 0,
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
// Global DSpace environment variables used in all our Cypress e2e tests
|
||||||
|
// May be modified in this config, or overridden in a variety of ways.
|
||||||
|
// See Cypress environment variable docs: https://docs.cypress.io/guides/guides/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: '6160810f-1e53-40db-81ef-f6621a727398',
|
||||||
|
// Search term (should return results) used in search tests
|
||||||
|
DSPACE_TEST_SEARCH_TERM: 'test',
|
||||||
|
// Main Collection used for submission tests. Should be able to accept normal Item objects
|
||||||
|
DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
|
||||||
|
DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
|
||||||
|
// Collection used for Person entity submission tests. MUST be configured with EntityType=Person.
|
||||||
|
DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People',
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
});
|
25
cypress.json
25
cypress.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
28
cypress/e2e/admin-sidebar.cy.ts
Normal file
28
cypress/e2e/admin-sidebar.cy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Options } from 'cypress-axe';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Admin Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Must login as an Admin for sidebar to appear
|
||||||
|
cy.visit('/login');
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be pinnable and pass accessibility tests', () => {
|
||||||
|
// Pin the sidebar open
|
||||||
|
cy.get('#sidebar-collapse-toggle').click();
|
||||||
|
|
||||||
|
// Click on every expandable section to open all menus
|
||||||
|
cy.get('ds-expandable-admin-sidebar-section').click({multiple: true});
|
||||||
|
|
||||||
|
// Analyze <ds-admin-sidebar> for accessibility
|
||||||
|
testA11y('ds-admin-sidebar',
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Currently all expandable sections have nested interactive elements
|
||||||
|
// See https://github.com/DSpace/dspace-angular/issues/2178
|
||||||
|
'nested-interactive': { enabled: false },
|
||||||
|
}
|
||||||
|
} as Options);
|
||||||
|
});
|
||||||
|
});
|
@@ -1,10 +1,9 @@
|
|||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Breadcrumbs', () => {
|
describe('Breadcrumbs', () => {
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
// Visit an Item, as those have more breadcrumbs
|
// Visit an Item, as those have more breadcrumbs
|
||||||
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
|
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
|
||||||
|
|
||||||
// Wait for breadcrumbs to be visible
|
// Wait for breadcrumbs to be visible
|
||||||
cy.get('ds-breadcrumbs').should('be.visible');
|
cy.get('ds-breadcrumbs').should('be.visible');
|
@@ -5,9 +5,9 @@ describe('Browse By Author', () => {
|
|||||||
cy.visit('/browse/author');
|
cy.visit('/browse/author');
|
||||||
|
|
||||||
// Wait for <ds-browse-by-metadata-page> to be visible
|
// Wait for <ds-browse-by-metadata-page> to be visible
|
||||||
cy.get('ds-browse-by-metadata-page').should('be.visible');
|
cy.get('ds-browse-by-metadata').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-browse-by-metadata-page> for accessibility
|
// Analyze <ds-browse-by-metadata-page> for accessibility
|
||||||
testA11y('ds-browse-by-metadata-page');
|
testA11y('ds-browse-by-metadata');
|
||||||
});
|
});
|
||||||
});
|
});
|
@@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => {
|
|||||||
cy.visit('/browse/dateissued');
|
cy.visit('/browse/dateissued');
|
||||||
|
|
||||||
// Wait for <ds-browse-by-date-page> to be visible
|
// Wait for <ds-browse-by-date-page> to be visible
|
||||||
cy.get('ds-browse-by-date-page').should('be.visible');
|
cy.get('ds-browse-by-date').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-browse-by-date-page> for accessibility
|
// Analyze <ds-browse-by-date-page> for accessibility
|
||||||
testA11y('ds-browse-by-date-page');
|
testA11y('ds-browse-by-date');
|
||||||
});
|
});
|
||||||
});
|
});
|
@@ -5,9 +5,9 @@ describe('Browse By Subject', () => {
|
|||||||
cy.visit('/browse/subject');
|
cy.visit('/browse/subject');
|
||||||
|
|
||||||
// Wait for <ds-browse-by-metadata-page> to be visible
|
// Wait for <ds-browse-by-metadata-page> to be visible
|
||||||
cy.get('ds-browse-by-metadata-page').should('be.visible');
|
cy.get('ds-browse-by-metadata').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-browse-by-metadata-page> for accessibility
|
// Analyze <ds-browse-by-metadata-page> for accessibility
|
||||||
testA11y('ds-browse-by-metadata-page');
|
testA11y('ds-browse-by-metadata');
|
||||||
});
|
});
|
||||||
});
|
});
|
@@ -5,9 +5,9 @@ describe('Browse By Title', () => {
|
|||||||
cy.visit('/browse/title');
|
cy.visit('/browse/title');
|
||||||
|
|
||||||
// Wait for <ds-browse-by-title-page> to be visible
|
// Wait for <ds-browse-by-title-page> to be visible
|
||||||
cy.get('ds-browse-by-title-page').should('be.visible');
|
cy.get('ds-browse-by-title').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-browse-by-title-page> for accessibility
|
// Analyze <ds-browse-by-title-page> for accessibility
|
||||||
testA11y('ds-browse-by-title-page');
|
testA11y('ds-browse-by-title');
|
||||||
});
|
});
|
||||||
});
|
});
|
128
cypress/e2e/collection-edit.cy.ts
Normal file
128
cypress/e2e/collection-edit.cy.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// All tests start with visiting the Edit Collection Page
|
||||||
|
cy.visit(COLLECTION_EDIT_PAGE);
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Edit Metadata tab', () => {
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
// <ds-edit-collection> tag must be loaded
|
||||||
|
cy.get('ds-edit-collection').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-edit-collection> for accessibility issues
|
||||||
|
testA11y('ds-edit-collection');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Assign Roles tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="roles"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-roles> tag must be loaded
|
||||||
|
cy.get('ds-collection-roles').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-collection-roles');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Content Source tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="source"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-source> tag must be loaded
|
||||||
|
cy.get('ds-collection-source').should('be.visible');
|
||||||
|
|
||||||
|
// Check the external source checkbox (to display all fields on the page)
|
||||||
|
cy.get('#externalSourceCheck').check();
|
||||||
|
|
||||||
|
// Wait for the source controls to appear
|
||||||
|
// cy.get('ds-collection-source-controls').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze entire page for accessibility issues
|
||||||
|
testA11y('ds-collection-source');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Curate tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="curate"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-curate> tag must be loaded
|
||||||
|
cy.get('ds-collection-curate').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-collection-curate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Access Control tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="access-control"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-access-control> tag must be loaded
|
||||||
|
cy.get('ds-collection-access-control').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-collection-access-control');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Authorizations tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="authorizations"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-authorizations> tag must be loaded
|
||||||
|
cy.get('ds-collection-authorizations').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-collection-authorizations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Collection > Item Mapper tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="mapper"]').click();
|
||||||
|
|
||||||
|
// <ds-collection-item-mapper> tag must be loaded
|
||||||
|
cy.get('ds-collection-item-mapper').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze entire page for accessibility issues
|
||||||
|
testA11y('ds-collection-item-mapper');
|
||||||
|
|
||||||
|
// Click on the "Map new Items" tab
|
||||||
|
cy.get('li[data-test="mapTab"] a').click();
|
||||||
|
|
||||||
|
// Make sure search form is now visible
|
||||||
|
cy.get('ds-search-form').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze entire page (again) for accessibility issues
|
||||||
|
testA11y('ds-collection-item-mapper');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('Edit Collection > Delete page', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="delete-button"]').click();
|
||||||
|
|
||||||
|
// <ds-delete-collection> tag must be loaded
|
||||||
|
cy.get('ds-delete-collection').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-delete-collection');
|
||||||
|
});
|
||||||
|
});
|
@@ -1,13 +1,12 @@
|
|||||||
import { TEST_COLLECTION } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Collection Page', () => {
|
describe('Collection Page', () => {
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
cy.visit('/collections/' + TEST_COLLECTION);
|
cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
|
||||||
|
|
||||||
// <ds-collection-page> tag must be loaded
|
// <ds-collection-page> tag must be loaded
|
||||||
cy.get('ds-collection-page').should('exist');
|
cy.get('ds-collection-page').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-collection-page> for accessibility issues
|
// Analyze <ds-collection-page> for accessibility issues
|
||||||
testA11y('ds-collection-page');
|
testA11y('ds-collection-page');
|
37
cypress/e2e/collection-statistics.cy.ts
Normal file
37
cypress/e2e/collection-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Collection Statistics Page', () => {
|
||||||
|
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'));
|
||||||
|
|
||||||
|
it('should load if you click on "Statistics" from a Collection page', () => {
|
||||||
|
cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||||
|
|
||||||
|
// <ds-collection-statistics-page> 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 <ds-collection-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-collection-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
86
cypress/e2e/community-edit.cy.ts
Normal file
86
cypress/e2e/community-edit.cy.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// All tests start with visiting the Edit Community Page
|
||||||
|
cy.visit(COMMUNITY_EDIT_PAGE);
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Edit Metadata tab', () => {
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
// <ds-edit-community> tag must be loaded
|
||||||
|
cy.get('ds-edit-community').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-edit-community> for accessibility issues
|
||||||
|
testA11y('ds-edit-community');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Assign Roles tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="roles"]').click();
|
||||||
|
|
||||||
|
// <ds-community-roles> tag must be loaded
|
||||||
|
cy.get('ds-community-roles').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-community-roles');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Curate tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="curate"]').click();
|
||||||
|
|
||||||
|
// <ds-community-curate> tag must be loaded
|
||||||
|
cy.get('ds-community-curate').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-community-curate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Access Control tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="access-control"]').click();
|
||||||
|
|
||||||
|
// <ds-community-access-control> tag must be loaded
|
||||||
|
cy.get('ds-community-access-control').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-community-access-control');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Authorizations tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="authorizations"]').click();
|
||||||
|
|
||||||
|
// <ds-community-authorizations> tag must be loaded
|
||||||
|
cy.get('ds-community-authorizations').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-community-authorizations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Community > Delete page', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="delete-button"]').click();
|
||||||
|
|
||||||
|
// <ds-delete-community> tag must be loaded
|
||||||
|
cy.get('ds-delete-community').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-delete-community');
|
||||||
|
});
|
||||||
|
});
|
17
cypress/e2e/community-list.cy.ts
Normal file
17
cypress/e2e/community-list.cy.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Community List Page', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit('/community-list');
|
||||||
|
|
||||||
|
// <ds-community-list-page> tag must be loaded
|
||||||
|
cy.get('ds-community-list-page').should('be.visible');
|
||||||
|
|
||||||
|
// 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 <ds-community-list-page> for accessibility issues
|
||||||
|
testA11y('ds-community-list-page');
|
||||||
|
});
|
||||||
|
});
|
@@ -1,15 +1,14 @@
|
|||||||
import { TEST_COMMUNITY } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Community Page', () => {
|
describe('Community Page', () => {
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
|
||||||
|
|
||||||
// <ds-community-page> tag must be loaded
|
// <ds-community-page> tag must be loaded
|
||||||
cy.get('ds-community-page').should('exist');
|
cy.get('ds-community-page').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-community-page> for accessibility issues
|
// Analyze <ds-community-page> for accessibility issues
|
||||||
testA11y('ds-community-page',);
|
testA11y('ds-community-page');
|
||||||
});
|
});
|
||||||
});
|
});
|
37
cypress/e2e/community-statistics.cy.ts
Normal file
37
cypress/e2e/community-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Community Statistics Page', () => {
|
||||||
|
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'));
|
||||||
|
|
||||||
|
it('should load if you click on "Statistics" from a Community page', () => {
|
||||||
|
cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||||
|
|
||||||
|
// <ds-community-statistics-page> 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 <ds-community-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-community-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/e2e/header.cy.ts
Normal file
13
cypress/e2e/header.cy.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
// Header must first be visible
|
||||||
|
cy.get('ds-header').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-header> for accessibility
|
||||||
|
testA11y('ds-header');
|
||||||
|
});
|
||||||
|
});
|
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT } 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[data-test="link-menu-item.menu.section.statistics"]').click();
|
||||||
|
cy.location('pathname').should('eq', '/statistics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
// generate 2 view events on an Item's page
|
||||||
|
cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
|
||||||
|
cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
|
||||||
|
|
||||||
|
cy.visit('/statistics');
|
||||||
|
|
||||||
|
// <ds-site-statistics-page> 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 <ds-site-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-site-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
@@ -6,8 +6,8 @@ describe('Homepage', () => {
|
|||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display translated title "DSpace Angular :: Home"', () => {
|
it('should display translated title "DSpace Repository :: Home"', () => {
|
||||||
cy.title().should('eq', 'DSpace Angular :: Home');
|
cy.title().should('eq', 'DSpace Repository :: Home');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain a news section', () => {
|
it('should contain a news section', () => {
|
135
cypress/e2e/item-edit.cy.ts
Normal file
135
cypress/e2e/item-edit.cy.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Options } from 'cypress-axe';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// All tests start with visiting the Edit Item Page
|
||||||
|
cy.visit(ITEM_EDIT_PAGE);
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Edit Metadata tab', () => {
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="metadata"]').click();
|
||||||
|
|
||||||
|
// <ds-edit-item-page> tag must be loaded
|
||||||
|
cy.get('ds-edit-item-page').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-edit-item-page> for accessibility issues
|
||||||
|
testA11y('ds-edit-item-page');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Status tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="status"]').click();
|
||||||
|
|
||||||
|
// <ds-item-status> tag must be loaded
|
||||||
|
cy.get('ds-item-status').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-status');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Bitstreams tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="bitstreams"]').click();
|
||||||
|
|
||||||
|
// <ds-item-bitstreams> tag must be loaded
|
||||||
|
cy.get('ds-item-bitstreams').should('be.visible');
|
||||||
|
|
||||||
|
// Table of item bitstreams must also be loaded
|
||||||
|
cy.get('div.item-bitstreams').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-bitstreams',
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Currently Bitstreams page loads a pagination component per Bundle
|
||||||
|
// and they all use the same 'id="p-dad"'.
|
||||||
|
'duplicate-id': { enabled: false },
|
||||||
|
}
|
||||||
|
} as Options
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Curate tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="curate"]').click();
|
||||||
|
|
||||||
|
// <ds-item-curate> tag must be loaded
|
||||||
|
cy.get('ds-item-curate').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-curate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Relationships tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="relationships"]').click();
|
||||||
|
|
||||||
|
// <ds-item-relationships> tag must be loaded
|
||||||
|
cy.get('ds-item-relationships').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-relationships');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Version History tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="versionhistory"]').click();
|
||||||
|
|
||||||
|
// <ds-item-version-history> tag must be loaded
|
||||||
|
cy.get('ds-item-version-history').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-version-history');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Access Control tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="access-control"]').click();
|
||||||
|
|
||||||
|
// <ds-item-access-control> tag must be loaded
|
||||||
|
cy.get('ds-item-access-control').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze for accessibility issues
|
||||||
|
testA11y('ds-item-access-control');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edit Item > Collection Mapper tab', () => {
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.get('a[data-test="mapper"]').click();
|
||||||
|
|
||||||
|
// <ds-item-collection-mapper> tag must be loaded
|
||||||
|
cy.get('ds-item-collection-mapper').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze entire page for accessibility issues
|
||||||
|
testA11y('ds-item-collection-mapper');
|
||||||
|
|
||||||
|
// Click on the "Map new collections" tab
|
||||||
|
cy.get('li[data-test="mapTab"] a').click();
|
||||||
|
|
||||||
|
// Make sure search form is now visible
|
||||||
|
cy.get('ds-search-form').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze entire page (again) for accessibility issues
|
||||||
|
testA11y('ds-item-collection-mapper');
|
||||||
|
});
|
||||||
|
});
|
32
cypress/e2e/item-page.cy.ts
Normal file
32
cypress/e2e/item-page.cy.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Item Page', () => {
|
||||||
|
const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
|
||||||
|
const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_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', () => {
|
||||||
|
cy.visit(ITEMPAGE);
|
||||||
|
cy.location('pathname').should('eq', ENTITYPAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(ENTITYPAGE);
|
||||||
|
|
||||||
|
// <ds-item-page> tag must be loaded
|
||||||
|
cy.get('ds-item-page').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-item-page> for accessibility issues
|
||||||
|
testA11y('ds-item-page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests on full item page', () => {
|
||||||
|
cy.visit(ENTITYPAGE + '/full');
|
||||||
|
|
||||||
|
// <ds-full-item-page> tag must be loaded
|
||||||
|
cy.get('ds-full-item-page').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-full-item-page> for accessibility issues
|
||||||
|
testA11y('ds-full-item-page');
|
||||||
|
});
|
||||||
|
});
|
43
cypress/e2e/item-statistics.cy.ts
Normal file
43
cypress/e2e/item-statistics.cy.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Item Statistics Page', () => {
|
||||||
|
const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
|
||||||
|
|
||||||
|
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
||||||
|
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.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('be.visible');
|
||||||
|
cy.get('ds-item-page').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits" section', () => {
|
||||||
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
|
cy.get('table[data-test="TotalVisits"]').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits per month" section', () => {
|
||||||
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
|
// Check just for existence because this table is empty in CI environment as it's historical data
|
||||||
|
cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
|
|
||||||
|
// <ds-item-statistics-page> tag must be loaded
|
||||||
|
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 <ds-item-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-item-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
@@ -1,33 +1,33 @@
|
|||||||
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
const page = {
|
const page = {
|
||||||
openLoginMenu() {
|
openLoginMenu() {
|
||||||
// Click the "Log In" dropdown menu in header
|
// Click the "Log In" dropdown menu in header
|
||||||
cy.get('ds-themed-navbar [data-test="login-menu"]').click();
|
cy.get('ds-themed-header [data-test="login-menu"]').click();
|
||||||
},
|
},
|
||||||
openUserMenu() {
|
openUserMenu() {
|
||||||
// Once logged in, click the User menu in header
|
// Once logged in, click the User menu in header
|
||||||
cy.get('ds-themed-navbar [data-test="user-menu"]').click();
|
cy.get('ds-themed-header [data-test="user-menu"]').click();
|
||||||
},
|
},
|
||||||
submitLoginAndPasswordByPressingButton(email, password) {
|
submitLoginAndPasswordByPressingButton(email, password) {
|
||||||
// Enter email
|
// Enter email
|
||||||
cy.get('ds-themed-navbar [data-test="email"]').type(email);
|
cy.get('ds-themed-header [data-test="email"]').type(email);
|
||||||
// Enter password
|
// Enter password
|
||||||
cy.get('ds-themed-navbar [data-test="password"]').type(password);
|
cy.get('ds-themed-header [data-test="password"]').type(password);
|
||||||
// Click login button
|
// Click login button
|
||||||
cy.get('ds-themed-navbar [data-test="login-button"]').click();
|
cy.get('ds-themed-header [data-test="login-button"]').click();
|
||||||
},
|
},
|
||||||
submitLoginAndPasswordByPressingEnter(email, password) {
|
submitLoginAndPasswordByPressingEnter(email, password) {
|
||||||
// In opened Login modal, fill out email & password, then click Enter
|
// In opened Login modal, fill out email & password, then click Enter
|
||||||
cy.get('ds-themed-navbar [data-test="email"]').type(email);
|
cy.get('ds-themed-header [data-test="email"]').type(email);
|
||||||
cy.get('ds-themed-navbar [data-test="password"]').type(password);
|
cy.get('ds-themed-header [data-test="password"]').type(password);
|
||||||
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}');
|
cy.get('ds-themed-header [data-test="password"]').type('{enter}');
|
||||||
},
|
},
|
||||||
submitLogoutByPressingButton() {
|
submitLogoutByPressingButton() {
|
||||||
// This is the POST command that will actually log us out
|
// This is the POST command that will actually log us out
|
||||||
cy.intercept('POST', '/server/api/authn/logout').as('logout');
|
cy.intercept('POST', '/server/api/authn/logout').as('logout');
|
||||||
// Click logout button
|
// Click logout button
|
||||||
cy.get('ds-themed-navbar [data-test="logout-button"]').click();
|
cy.get('ds-themed-header [data-test="logout-button"]').click();
|
||||||
// Wait until above POST command responds before continuing
|
// Wait until above POST command responds before continuing
|
||||||
// (This ensures next action waits until logout completes)
|
// (This ensures next action waits until logout completes)
|
||||||
cy.wait('@logout');
|
cy.wait('@logout');
|
||||||
@@ -36,7 +36,7 @@ const page = {
|
|||||||
|
|
||||||
describe('Login Modal', () => {
|
describe('Login Modal', () => {
|
||||||
it('should login when clicking button & stay on same page', () => {
|
it('should login when clicking button & stay on same page', () => {
|
||||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
|
||||||
cy.visit(ENTITYPAGE);
|
cy.visit(ENTITYPAGE);
|
||||||
|
|
||||||
// Login menu should exist
|
// Login menu should exist
|
||||||
@@ -46,7 +46,7 @@ describe('Login Modal', () => {
|
|||||||
page.openLoginMenu();
|
page.openLoginMenu();
|
||||||
cy.get('.form-login').should('be.visible');
|
cy.get('.form-login').should('be.visible');
|
||||||
|
|
||||||
page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
|
page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
cy.get('ds-log-in').should('not.exist');
|
cy.get('ds-log-in').should('not.exist');
|
||||||
|
|
||||||
// Verify we are still on the same page
|
// Verify we are still on the same page
|
||||||
@@ -66,7 +66,7 @@ describe('Login Modal', () => {
|
|||||||
cy.get('.form-login').should('be.visible');
|
cy.get('.form-login').should('be.visible');
|
||||||
|
|
||||||
// Login, and the <ds-log-in> tag should no longer exist
|
// Login, and the <ds-log-in> tag should no longer exist
|
||||||
page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
|
page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
cy.get('.form-login').should('not.exist');
|
cy.get('.form-login').should('not.exist');
|
||||||
|
|
||||||
// Verify we are still on homepage
|
// Verify we are still on homepage
|
||||||
@@ -80,7 +80,7 @@ describe('Login Modal', () => {
|
|||||||
|
|
||||||
it('should support logout', () => {
|
it('should support logout', () => {
|
||||||
// First authenticate & access homepage
|
// First authenticate & access homepage
|
||||||
cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
|
cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
|
|
||||||
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
|
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
|
||||||
@@ -102,12 +102,15 @@ describe('Login Modal', () => {
|
|||||||
page.openLoginMenu();
|
page.openLoginMenu();
|
||||||
|
|
||||||
// Registration link should be visible
|
// Registration link should be visible
|
||||||
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible');
|
cy.get('ds-themed-header [data-test="register"]').should('be.visible');
|
||||||
|
|
||||||
// Click registration link & you should go to registration page
|
// Click registration link & you should go to registration page
|
||||||
cy.get('ds-themed-navbar [data-test="register"]').click();
|
cy.get('ds-themed-header [data-test="register"]').click();
|
||||||
cy.location('pathname').should('eq', '/register');
|
cy.location('pathname').should('eq', '/register');
|
||||||
cy.get('ds-register-email').should('exist');
|
cy.get('ds-register-email').should('exist');
|
||||||
|
|
||||||
|
// Test accessibility of this page
|
||||||
|
testA11y('ds-register-email');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow forgot password', () => {
|
it('should allow forgot password', () => {
|
||||||
@@ -116,11 +119,32 @@ describe('Login Modal', () => {
|
|||||||
page.openLoginMenu();
|
page.openLoginMenu();
|
||||||
|
|
||||||
// Forgot password link should be visible
|
// Forgot password link should be visible
|
||||||
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible');
|
cy.get('ds-themed-header [data-test="forgot"]').should('be.visible');
|
||||||
|
|
||||||
// Click link & you should go to Forgot Password page
|
// Click link & you should go to Forgot Password page
|
||||||
cy.get('ds-themed-navbar [data-test="forgot"]').click();
|
cy.get('ds-themed-header [data-test="forgot"]').click();
|
||||||
cy.location('pathname').should('eq', '/forgot');
|
cy.location('pathname').should('eq', '/forgot');
|
||||||
cy.get('ds-forgot-email').should('exist');
|
cy.get('ds-forgot-email').should('exist');
|
||||||
|
|
||||||
|
// Test accessibility of this page
|
||||||
|
testA11y('ds-forgot-email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests in menus', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
// Open login menu & verify accessibility
|
||||||
|
page.openLoginMenu();
|
||||||
|
cy.get('ds-log-in').should('exist');
|
||||||
|
testA11y('ds-log-in');
|
||||||
|
|
||||||
|
// Now login
|
||||||
|
page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
cy.get('ds-log-in').should('not.exist');
|
||||||
|
|
||||||
|
// Open user menu, verify user menu accesibility
|
||||||
|
page.openUserMenu();
|
||||||
|
cy.get('ds-user-menu').should('be.visible');
|
||||||
|
testA11y('ds-user-menu');
|
||||||
});
|
});
|
||||||
});
|
});
|
@@ -1,14 +1,13 @@
|
|||||||
import { Options } from 'cypress-axe';
|
|
||||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('My DSpace page', () => {
|
describe('My DSpace page', () => {
|
||||||
it('should display recent submissions and pass accessibility tests', () => {
|
it('should display recent submissions and pass accessibility tests', () => {
|
||||||
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
|
||||||
|
|
||||||
cy.visit('/mydspace');
|
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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
|
cy.get('ds-my-dspace-page').should('be.visible');
|
||||||
|
|
||||||
// At least one recent submission should be displayed
|
// At least one recent submission should be displayed
|
||||||
cy.get('[data-test="list-object"]').should('be.visible');
|
cy.get('[data-test="list-object"]').should('be.visible');
|
||||||
@@ -18,52 +17,33 @@ describe('My DSpace page', () => {
|
|||||||
cy.get('.filter-toggle').click({ multiple: true });
|
cy.get('.filter-toggle').click({ multiple: true });
|
||||||
|
|
||||||
// Analyze <ds-my-dspace-page> for accessibility issues
|
// Analyze <ds-my-dspace-page> for accessibility issues
|
||||||
testA11y(
|
testA11y('ds-my-dspace-page');
|
||||||
{
|
|
||||||
include: ['ds-my-dspace-page'],
|
|
||||||
exclude: [
|
|
||||||
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
// Search filters fail these two "moderate" impact rules
|
|
||||||
'heading-order': { enabled: false },
|
|
||||||
'landmark-unique': { enabled: false }
|
|
||||||
}
|
|
||||||
} as Options
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have a working detailed view that passes accessibility tests', () => {
|
it('should have a working detailed view that passes accessibility tests', () => {
|
||||||
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
|
||||||
|
|
||||||
cy.visit('/mydspace');
|
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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
|
cy.get('ds-my-dspace-page').should('be.visible');
|
||||||
|
|
||||||
// Click button in sidebar to display detailed view
|
// Click button in sidebar to display detailed view
|
||||||
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
|
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 <ds-search-page> for accessibility issues
|
// Analyze <ds-my-dspace-page> for accessibility issues
|
||||||
testA11y('ds-my-dspace-page',
|
testA11y('ds-my-dspace-page');
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
// Search filters fail these two "moderate" impact rules
|
|
||||||
'heading-order': { enabled: false },
|
|
||||||
'landmark-unique': { enabled: false }
|
|
||||||
}
|
|
||||||
} as Options
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Deleting existing submissions is exercised by submission.spec.ts
|
// NOTE: Deleting existing submissions is exercised by submission.spec.ts
|
||||||
it('should let you start a new submission & edit in-progress submissions', () => {
|
it('should let you start a new submission & edit in-progress submissions', () => {
|
||||||
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
|
||||||
cy.visit('/mydspace');
|
cy.visit('/mydspace');
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
// Open the New Submission dropdown
|
// Open the New Submission dropdown
|
||||||
cy.get('button[data-test="submission-dropdown"]').click();
|
cy.get('button[data-test="submission-dropdown"]').click();
|
||||||
// Click on the "Item" type in that dropdown
|
// Click on the "Item" type in that dropdown
|
||||||
@@ -73,10 +53,10 @@ describe('My DSpace page', () => {
|
|||||||
cy.get('ds-create-item-parent-selector').should('be.visible');
|
cy.get('ds-create-item-parent-selector').should('be.visible');
|
||||||
|
|
||||||
// Type in a known Collection name in the search box
|
// Type in a known Collection name in the search box
|
||||||
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
|
cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME'));
|
||||||
|
|
||||||
// Click on the button matching that known 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(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click();
|
||||||
|
|
||||||
// New URL should include /workspaceitems, as we've started a new submission
|
// New URL should include /workspaceitems, as we've started a new submission
|
||||||
cy.url().should('include', '/workspaceitems');
|
cy.url().should('include', '/workspaceitems');
|
||||||
@@ -85,7 +65,7 @@ describe('My DSpace page', () => {
|
|||||||
cy.get('ds-submission-edit').should('be.visible');
|
cy.get('ds-submission-edit').should('be.visible');
|
||||||
|
|
||||||
// A Collection menu button should exist & its value should be the selected collection
|
// A Collection menu button should exist & its value should be the selected collection
|
||||||
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME);
|
cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME'));
|
||||||
|
|
||||||
// Now that we've created a submission, we'll test that we can go back and Edit it.
|
// Now that we've created a submission, we'll test that we can go back and Edit it.
|
||||||
// Get our Submission URL, to parse out the ID of this new submission
|
// Get our Submission URL, to parse out the ID of this new submission
|
||||||
@@ -131,9 +111,11 @@ describe('My DSpace page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should let you import from external sources', () => {
|
it('should let you import from external sources', () => {
|
||||||
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
|
||||||
cy.visit('/mydspace');
|
cy.visit('/mydspace');
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
// Open the New Import dropdown
|
// Open the New Import dropdown
|
||||||
cy.get('button[data-test="import-dropdown"]').click();
|
cy.get('button[data-test="import-dropdown"]').click();
|
||||||
// Click on the "Item" type in that dropdown
|
// Click on the "Item" type in that dropdown
|
||||||
@@ -144,6 +126,9 @@ describe('My DSpace page', () => {
|
|||||||
|
|
||||||
// The external import searchbox should be visible
|
// The external import searchbox should be visible
|
||||||
cy.get('ds-submission-import-external-searchbar').should('be.visible');
|
cy.get('ds-submission-import-external-searchbar').should('be.visible');
|
||||||
|
|
||||||
|
// Test for accessibility issues
|
||||||
|
testA11y('ds-submission-import-external');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
@@ -1,8 +1,13 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('PageNotFound', () => {
|
describe('PageNotFound', () => {
|
||||||
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
|
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
|
||||||
// request an invalid page (UUIDs at root path aren't valid)
|
// request an invalid page (UUIDs at root path aren't valid)
|
||||||
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
|
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
|
||||||
cy.get('ds-pagenotfound').should('exist');
|
cy.get('ds-pagenotfound').should('be.visible');
|
||||||
|
|
||||||
|
// Analyze <ds-pagenotfound> for accessibility issues
|
||||||
|
testA11y('ds-pagenotfound');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
|
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
|
@@ -1,23 +1,21 @@
|
|||||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
|
||||||
|
|
||||||
const page = {
|
const page = {
|
||||||
fillOutQueryInNavBar(query) {
|
fillOutQueryInNavBar(query) {
|
||||||
// Click the magnifying glass
|
// Click the magnifying glass
|
||||||
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
|
cy.get('ds-themed-header [data-test="header-search-icon"]').click();
|
||||||
// Fill out a query in input that appears
|
// Fill out a query in input that appears
|
||||||
cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query);
|
cy.get('ds-themed-header [data-test="header-search-box"]').type(query);
|
||||||
},
|
},
|
||||||
submitQueryByPressingEnter() {
|
submitQueryByPressingEnter() {
|
||||||
cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}');
|
cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}');
|
||||||
},
|
},
|
||||||
submitQueryByPressingIcon() {
|
submitQueryByPressingIcon() {
|
||||||
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
|
cy.get('ds-themed-header [data-test="header-search-icon"]').click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Search from Navigation Bar', () => {
|
describe('Search from Navigation Bar', () => {
|
||||||
// NOTE: these tests currently assume this query will return results!
|
// NOTE: these tests currently assume this query will return results!
|
||||||
const query = TEST_SEARCH_TERM;
|
const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');
|
||||||
|
|
||||||
it('should go to search page with correct query if submitted (from home)', () => {
|
it('should go to search page with correct query if submitted (from home)', () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
@@ -27,7 +25,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingEnter();
|
page.submitQueryByPressingEnter();
|
||||||
// New URL should include query param
|
// 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
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
||||||
@@ -42,7 +40,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingEnter();
|
page.submitQueryByPressingEnter();
|
||||||
// New URL should include query param
|
// 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
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
||||||
@@ -57,7 +55,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingIcon();
|
page.submitQueryByPressingIcon();
|
||||||
// New URL should include query param
|
// 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
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
@@ -1,8 +1,10 @@
|
|||||||
import { Options } from 'cypress-axe';
|
import { Options } from 'cypress-axe';
|
||||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Search Page', () => {
|
describe('Search Page', () => {
|
||||||
|
// NOTE: these tests currently assume this query will return results!
|
||||||
|
const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');
|
||||||
|
|
||||||
it('should redirect to the correct url when query was set and submit button was triggered', () => {
|
it('should redirect to the correct url when query was set and submit button was triggered', () => {
|
||||||
const queryString = 'Another interesting query string';
|
const queryString = 'Another interesting query string';
|
||||||
cy.visit('/search');
|
cy.visit('/search');
|
||||||
@@ -13,11 +15,11 @@ describe('Search Page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should load results and pass accessibility tests', () => {
|
it('should load results and pass accessibility tests', () => {
|
||||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
cy.visit('/search?query='.concat(query));
|
||||||
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
|
cy.get('[data-test="search-box"]').should('have.value', query);
|
||||||
|
|
||||||
// <ds-search-page> tag must be loaded
|
// <ds-search-page> 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
|
// At least one search result should be displayed
|
||||||
cy.get('[data-test="list-object"]').should('be.visible');
|
cy.get('[data-test="list-object"]').should('be.visible');
|
||||||
@@ -27,31 +29,17 @@ describe('Search Page', () => {
|
|||||||
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
|
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
|
||||||
|
|
||||||
// Analyze <ds-search-page> for accessibility issues
|
// Analyze <ds-search-page> for accessibility issues
|
||||||
testA11y(
|
testA11y('ds-search-page');
|
||||||
{
|
|
||||||
include: ['ds-search-page'],
|
|
||||||
exclude: [
|
|
||||||
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
// Search filters fail these two "moderate" impact rules
|
|
||||||
'heading-order': { enabled: false },
|
|
||||||
'landmark-unique': { enabled: false }
|
|
||||||
}
|
|
||||||
} as Options
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have a working grid view that passes accessibility tests', () => {
|
it('should have a working grid view that passes accessibility tests', () => {
|
||||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
cy.visit('/search?query='.concat(query));
|
||||||
|
|
||||||
// Click button in sidebar to display grid view
|
// Click button in sidebar to display grid view
|
||||||
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
|
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
|
||||||
|
|
||||||
// <ds-search-page> tag must be loaded
|
// <ds-search-page> 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
|
// At least one grid object (card) should be displayed
|
||||||
cy.get('[data-test="grid-object"]').should('be.visible');
|
cy.get('[data-test="grid-object"]').should('be.visible');
|
||||||
@@ -60,9 +48,8 @@ describe('Search Page', () => {
|
|||||||
testA11y('ds-search-page',
|
testA11y('ds-search-page',
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
// Search filters fail these two "moderate" impact rules
|
// Card titles fail this test currently
|
||||||
'heading-order': { enabled: false },
|
'heading-order': { enabled: false }
|
||||||
'landmark-unique': { enabled: false }
|
|
||||||
}
|
}
|
||||||
} as Options
|
} as Options
|
||||||
);
|
);
|
227
cypress/e2e/submission.cy.ts
Normal file
227
cypress/e2e/submission.cy.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
//import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e';
|
||||||
|
import { Options } from 'cypress-axe';
|
||||||
|
|
||||||
|
describe('New Submission page', () => {
|
||||||
|
|
||||||
|
// NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts
|
||||||
|
it('should create a new submission when using /submit path & pass accessibility', () => {
|
||||||
|
// Test that calling /submit with collection & entityType will create a new submission
|
||||||
|
cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
|
// Should redirect to /workspaceitems, as we've started a new submission
|
||||||
|
cy.url().should('include', '/workspaceitems');
|
||||||
|
|
||||||
|
// The Submission edit form tag should be visible
|
||||||
|
cy.get('ds-submission-edit').should('be.visible');
|
||||||
|
|
||||||
|
// A Collection menu button should exist & it's value should be the selected collection
|
||||||
|
cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME'));
|
||||||
|
|
||||||
|
// 4 sections should be visible by default
|
||||||
|
cy.get('div#section_traditionalpageone').should('be.visible');
|
||||||
|
cy.get('div#section_traditionalpagetwo').should('be.visible');
|
||||||
|
cy.get('div#section_upload').should('be.visible');
|
||||||
|
cy.get('div#section_license').should('be.visible');
|
||||||
|
|
||||||
|
// Test entire page for accessibility
|
||||||
|
testA11y('ds-submission-edit',
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Author & Subject fields have invalid "aria-multiline" attrs.
|
||||||
|
// See https://github.com/DSpace/dspace-angular/issues/1272
|
||||||
|
'aria-allowed-attr': { enabled: false },
|
||||||
|
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
|
||||||
|
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
|
||||||
|
'aria-required-children': { enabled: false },
|
||||||
|
'nested-interactive': { enabled: false },
|
||||||
|
// All select boxes fail to have a name / aria-label.
|
||||||
|
// This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216
|
||||||
|
'select-name': { enabled: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
} as Options
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discard button should work
|
||||||
|
// Clicking it will display a confirmation, which we will confirm with another click
|
||||||
|
cy.get('button#discard').click();
|
||||||
|
cy.get('button#discard_submit').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block submission & show errors if required fields are missing', () => {
|
||||||
|
// Create a new submission
|
||||||
|
cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
|
// Attempt an immediate deposit without filling out any fields
|
||||||
|
cy.get('button#deposit').click();
|
||||||
|
|
||||||
|
// A warning alert should display.
|
||||||
|
cy.get('ds-notification div.alert-success').should('not.exist');
|
||||||
|
cy.get('ds-notification div.alert-warning').should('be.visible');
|
||||||
|
|
||||||
|
// First section should have an exclamation error in the header
|
||||||
|
// (as it has required fields)
|
||||||
|
cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible');
|
||||||
|
|
||||||
|
// Title field should have class "is-invalid" applied, as it's required
|
||||||
|
cy.get('input#dc_title').should('have.class', 'is-invalid');
|
||||||
|
|
||||||
|
// Date Year field should also have "is-valid" class
|
||||||
|
cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid');
|
||||||
|
|
||||||
|
// FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button.
|
||||||
|
// Get our Submission URL, to parse out the ID of this submission
|
||||||
|
cy.location().then(fullUrl => {
|
||||||
|
// This will be the full path (/workspaceitems/[id]/edit)
|
||||||
|
const path = fullUrl.pathname;
|
||||||
|
// Split on the slashes
|
||||||
|
const subpaths = path.split('/');
|
||||||
|
// Part 2 will be the [id] of the submission
|
||||||
|
const id = subpaths[2];
|
||||||
|
|
||||||
|
// Even though form is incomplete, the "Save for Later" button should still work
|
||||||
|
cy.get('button#saveForLater').click();
|
||||||
|
|
||||||
|
// "Save for Later" should send us to MyDSpace
|
||||||
|
cy.url().should('include', '/mydspace');
|
||||||
|
|
||||||
|
// A success alert should be visible
|
||||||
|
cy.get('ds-notification div.alert-success').should('be.visible');
|
||||||
|
// Now, dismiss any open alert boxes (may be multiple, as tests run quickly)
|
||||||
|
cy.get('[data-dismiss="alert"]').click({multiple: true});
|
||||||
|
|
||||||
|
// This is the GET command that will actually run the search
|
||||||
|
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
|
||||||
|
// On MyDSpace, find the submission we just saved via its ID
|
||||||
|
cy.get('[data-test="search-box"]').type(id);
|
||||||
|
cy.get('[data-test="search-button"]').click();
|
||||||
|
|
||||||
|
// Wait for search results to come back from the above GET command
|
||||||
|
cy.wait('@search-results');
|
||||||
|
|
||||||
|
// Delete our created submission & confirm deletion
|
||||||
|
cy.get('button#delete_' + id).click();
|
||||||
|
cy.get('button#delete_confirm').click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow for deposit if all required fields completed & file uploaded', () => {
|
||||||
|
// Create a new submission
|
||||||
|
cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
|
||||||
|
|
||||||
|
// Fill out all required fields (Title, Date)
|
||||||
|
cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests');
|
||||||
|
cy.get('input#dc_date_issued_year').type('2022');
|
||||||
|
|
||||||
|
// Confirm the required license by checking checkbox
|
||||||
|
// (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own <span>)
|
||||||
|
cy.get('input#granted').check( {force: true} );
|
||||||
|
|
||||||
|
// Before using Cypress drag & drop, we have to manually trigger the "dragover" event.
|
||||||
|
// This ensures our UI displays the dropzone that covers the entire submission page.
|
||||||
|
// (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger)
|
||||||
|
cy.get('ds-uploader').trigger('dragover');
|
||||||
|
|
||||||
|
// This is the POST command that will upload the file
|
||||||
|
cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload');
|
||||||
|
|
||||||
|
// Upload our DSpace logo via drag & drop onto submission form
|
||||||
|
// cy.get('div#section_upload')
|
||||||
|
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', {
|
||||||
|
action: 'drag-drop'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for upload to complete before proceeding
|
||||||
|
cy.wait('@upload');
|
||||||
|
|
||||||
|
// Wait for deposit button to not be disabled & click it.
|
||||||
|
cy.get('button#deposit').should('not.be.disabled').click();
|
||||||
|
|
||||||
|
// No warnings should exist. Instead, just successful deposit alert is displayed
|
||||||
|
cy.get('ds-notification div.alert-warning').should('not.exist');
|
||||||
|
cy.get('ds-notification div.alert-success').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is possible to submit a new "Person" and that form passes accessibility', () => {
|
||||||
|
// To submit a different entity type, we'll start from MyDSpace
|
||||||
|
cy.visit('/mydspace');
|
||||||
|
|
||||||
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
|
// NOTE: At this time, we MUST login as admin to submit Person objects
|
||||||
|
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
|
||||||
|
|
||||||
|
// Open the New Submission dropdown
|
||||||
|
cy.get('button[data-test="submission-dropdown"]').click();
|
||||||
|
// Click on the "Person" type in that dropdown
|
||||||
|
cy.get('#entityControlsDropdownMenu button[title="Person"]').click();
|
||||||
|
|
||||||
|
// This should display the <ds-create-item-parent-selector> (popup window)
|
||||||
|
cy.get('ds-create-item-parent-selector').should('be.visible');
|
||||||
|
|
||||||
|
// Type in a known Collection name in the search box
|
||||||
|
cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME'));
|
||||||
|
|
||||||
|
// Click on the button matching that known Collection name
|
||||||
|
cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click();
|
||||||
|
|
||||||
|
// New URL should include /workspaceitems, as we've started a new submission
|
||||||
|
cy.url().should('include', '/workspaceitems');
|
||||||
|
|
||||||
|
// The Submission edit form tag should be visible
|
||||||
|
cy.get('ds-submission-edit').should('be.visible');
|
||||||
|
|
||||||
|
// A Collection menu button should exist & its value should be the selected collection
|
||||||
|
cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME'));
|
||||||
|
|
||||||
|
// 3 sections should be visible by default
|
||||||
|
cy.get('div#section_personStep').should('be.visible');
|
||||||
|
cy.get('div#section_upload').should('be.visible');
|
||||||
|
cy.get('div#section_license').should('be.visible');
|
||||||
|
|
||||||
|
// Test entire page for accessibility
|
||||||
|
testA11y('ds-submission-edit',
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
|
||||||
|
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
|
||||||
|
'aria-required-children': { enabled: false },
|
||||||
|
'nested-interactive': { enabled: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
} as Options
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the lookup button next to "Publication" field
|
||||||
|
cy.get('button[data-test="lookup-button"]').click();
|
||||||
|
|
||||||
|
// A popup modal window should be visible
|
||||||
|
cy.get('ds-dynamic-lookup-relation-modal').should('be.visible');
|
||||||
|
|
||||||
|
// Popup modal should also pass accessibility tests
|
||||||
|
//testA11y('ds-dynamic-lookup-relation-modal');
|
||||||
|
testA11y({
|
||||||
|
include: ['ds-dynamic-lookup-relation-modal'],
|
||||||
|
exclude: [
|
||||||
|
['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close popup window
|
||||||
|
cy.get('ds-dynamic-lookup-relation-modal button.close').click();
|
||||||
|
|
||||||
|
// Back on the form, click the discard button to remove new submission
|
||||||
|
// Clicking it will display a confirmation, which we will confirm with another click
|
||||||
|
cy.get('button#discard').click();
|
||||||
|
cy.get('button#discard_submit').click();
|
||||||
|
});
|
||||||
|
});
|
@@ -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);
|
|
||||||
|
|
||||||
// <ds-collection-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-collection-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-collection-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-collection-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,25 +0,0 @@
|
|||||||
import { Options } from 'cypress-axe';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Community List Page', () => {
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit('/community-list');
|
|
||||||
|
|
||||||
// <ds-community-list-page> tag must be loaded
|
|
||||||
cy.get('ds-community-list-page').should('exist');
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Analyze <ds-community-list-page> for accessibility issues
|
|
||||||
// Disable heading-order checks until it is fixed
|
|
||||||
testA11y('ds-community-list-page',
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'heading-order': { enabled: false }
|
|
||||||
}
|
|
||||||
} as Options
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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);
|
|
||||||
|
|
||||||
// <ds-community-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-community-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-community-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-community-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,19 +0,0 @@
|
|||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Header', () => {
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit('/');
|
|
||||||
|
|
||||||
// Header must first be visible
|
|
||||||
cy.get('ds-header').should('be.visible');
|
|
||||||
|
|
||||||
// Analyze <ds-header> for accessibility
|
|
||||||
testA11y({
|
|
||||||
include: ['ds-header'],
|
|
||||||
exclude: [
|
|
||||||
['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
|
|
||||||
['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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');
|
|
||||||
|
|
||||||
// <ds-site-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-site-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-site-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-site-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,31 +0,0 @@
|
|||||||
import { Options } from 'cypress-axe';
|
|
||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Item Page', () => {
|
|
||||||
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
|
|
||||||
const ENTITYPAGE = '/entities/publication/' + 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', () => {
|
|
||||||
cy.visit(ITEMPAGE);
|
|
||||||
cy.location('pathname').should('eq', ENTITYPAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit(ENTITYPAGE);
|
|
||||||
|
|
||||||
// <ds-item-page> tag must be loaded
|
|
||||||
cy.get('ds-item-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-item-page> for accessibility issues
|
|
||||||
// Disable heading-order checks until it is fixed
|
|
||||||
testA11y('ds-item-page',
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'heading-order': { enabled: false }
|
|
||||||
}
|
|
||||||
} as Options
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,38 +0,0 @@
|
|||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Item Statistics Page', () => {
|
|
||||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
|
|
||||||
|
|
||||||
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
|
||||||
cy.visit('/entities/publication/' + 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-page').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits" section', () => {
|
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits per month" section', () => {
|
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
|
||||||
|
|
||||||
// <ds-item-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-item-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-item-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-item-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,135 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Should redirect to /workspaceitems, as we've started a new submission
|
|
||||||
cy.url().should('include', '/workspaceitems');
|
|
||||||
|
|
||||||
// The Submission edit form tag should be visible
|
|
||||||
cy.get('ds-submission-edit').should('be.visible');
|
|
||||||
|
|
||||||
// A Collection menu button should exist & it's value should be the selected collection
|
|
||||||
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME);
|
|
||||||
|
|
||||||
// 4 sections should be visible by default
|
|
||||||
cy.get('div#section_traditionalpageone').should('be.visible');
|
|
||||||
cy.get('div#section_traditionalpagetwo').should('be.visible');
|
|
||||||
cy.get('div#section_upload').should('be.visible');
|
|
||||||
cy.get('div#section_license').should('be.visible');
|
|
||||||
|
|
||||||
// Discard button should work
|
|
||||||
// Clicking it will display a confirmation, which we will confirm with another click
|
|
||||||
cy.get('button#discard').click();
|
|
||||||
cy.get('button#discard_submit').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Attempt an immediate deposit without filling out any fields
|
|
||||||
cy.get('button#deposit').click();
|
|
||||||
|
|
||||||
// A warning alert should display.
|
|
||||||
cy.get('ds-notification div.alert-success').should('not.exist');
|
|
||||||
cy.get('ds-notification div.alert-warning').should('be.visible');
|
|
||||||
|
|
||||||
// First section should have an exclamation error in the header
|
|
||||||
// (as it has required fields)
|
|
||||||
cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible');
|
|
||||||
|
|
||||||
// Title field should have class "is-invalid" applied, as it's required
|
|
||||||
cy.get('input#dc_title').should('have.class', 'is-invalid');
|
|
||||||
|
|
||||||
// Date Year field should also have "is-valid" class
|
|
||||||
cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid');
|
|
||||||
|
|
||||||
// FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button.
|
|
||||||
// Get our Submission URL, to parse out the ID of this submission
|
|
||||||
cy.location().then(fullUrl => {
|
|
||||||
// This will be the full path (/workspaceitems/[id]/edit)
|
|
||||||
const path = fullUrl.pathname;
|
|
||||||
// Split on the slashes
|
|
||||||
const subpaths = path.split('/');
|
|
||||||
// Part 2 will be the [id] of the submission
|
|
||||||
const id = subpaths[2];
|
|
||||||
|
|
||||||
// Even though form is incomplete, the "Save for Later" button should still work
|
|
||||||
cy.get('button#saveForLater').click();
|
|
||||||
|
|
||||||
// "Save for Later" should send us to MyDSpace
|
|
||||||
cy.url().should('include', '/mydspace');
|
|
||||||
|
|
||||||
// A success alert should be visible
|
|
||||||
cy.get('ds-notification div.alert-success').should('be.visible');
|
|
||||||
// Now, dismiss any open alert boxes (may be multiple, as tests run quickly)
|
|
||||||
cy.get('[data-dismiss="alert"]').click({multiple: true});
|
|
||||||
|
|
||||||
// This is the GET command that will actually run the search
|
|
||||||
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
|
|
||||||
// On MyDSpace, find the submission we just saved via its ID
|
|
||||||
cy.get('[data-test="search-box"]').type(id);
|
|
||||||
cy.get('[data-test="search-button"]').click();
|
|
||||||
|
|
||||||
// Wait for search results to come back from the above GET command
|
|
||||||
cy.wait('@search-results');
|
|
||||||
|
|
||||||
// Delete our created submission & confirm deletion
|
|
||||||
cy.get('button#delete_' + id).click();
|
|
||||||
cy.get('button#delete_confirm').click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Fill out all required fields (Title, Date)
|
|
||||||
cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests');
|
|
||||||
cy.get('input#dc_date_issued_year').type('2022');
|
|
||||||
|
|
||||||
// Confirm the required license by checking checkbox
|
|
||||||
// (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own <span>)
|
|
||||||
cy.get('input#granted').check( {force: true} );
|
|
||||||
|
|
||||||
// Before using Cypress drag & drop, we have to manually trigger the "dragover" event.
|
|
||||||
// This ensures our UI displays the dropzone that covers the entire submission page.
|
|
||||||
// (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger)
|
|
||||||
cy.get('ds-uploader').trigger('dragover');
|
|
||||||
|
|
||||||
// This is the POST command that will upload the file
|
|
||||||
cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload');
|
|
||||||
|
|
||||||
// Upload our DSpace logo via drag & drop onto submission form
|
|
||||||
// cy.get('div#section_upload')
|
|
||||||
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', {
|
|
||||||
action: 'drag-drop'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// No warnings should exist. Instead, just successful deposit alert is displayed
|
|
||||||
cy.get('ds-notification div.alert-warning').should('not.exist');
|
|
||||||
cy.get('ds-notification div.alert-success').should('be.visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
@@ -1,5 +1,11 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// These two global variables are used to store information about the REST API used
|
||||||
|
// by these e2e tests. They are filled out prior to running any tests in the before()
|
||||||
|
// method of e2e.ts. They can then be accessed by any tests via the getters below.
|
||||||
|
let REST_BASE_URL: string;
|
||||||
|
let REST_DOMAIN: string;
|
||||||
|
|
||||||
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
|
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
|
||||||
// For more info, visit https://on.cypress.io/plugins-api
|
// For more info, visit https://on.cypress.io/plugins-api
|
||||||
module.exports = (on, config) => {
|
module.exports = (on, config) => {
|
||||||
@@ -30,6 +36,24 @@ module.exports = (on, config) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
},
|
||||||
|
// Save value of REST Base URL, looked up before all tests.
|
||||||
|
// This allows other tests to use it easily via getRestBaseURL() below.
|
||||||
|
saveRestBaseURL(url: string) {
|
||||||
|
return (REST_BASE_URL = url);
|
||||||
|
},
|
||||||
|
// Retrieve currently saved value of REST Base URL
|
||||||
|
getRestBaseURL() {
|
||||||
|
return REST_BASE_URL ;
|
||||||
|
},
|
||||||
|
// Save value of REST Domain, looked up before all tests.
|
||||||
|
// This allows other tests to use it easily via getRestBaseDomain() below.
|
||||||
|
saveRestBaseDomain(domain: string) {
|
||||||
|
return (REST_DOMAIN = domain);
|
||||||
|
},
|
||||||
|
// Retrieve currently saved value of REST Domain
|
||||||
|
getRestBaseDomain() {
|
||||||
|
return REST_DOMAIN ;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -4,12 +4,13 @@
|
|||||||
// ***********************************************
|
// ***********************************************
|
||||||
|
|
||||||
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
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';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
|
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
|
||||||
// ALL custom commands MUST be listed here for code completion to work
|
// ALL custom commands MUST be listed here for code completion to work
|
||||||
// tslint:disable-next-line:no-namespace
|
|
||||||
declare global {
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface Chainable<Subject = any> {
|
interface Chainable<Subject = any> {
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,30 @@ declare global {
|
|||||||
* @param password password to login as
|
* @param password password to login as
|
||||||
*/
|
*/
|
||||||
login(email: string, password: string): typeof login;
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new CSRF token and add to required Cookie. CSRF Token is returned
|
||||||
|
* in chainable in order to allow it to be sent also in required CSRF header.
|
||||||
|
* @returns Chainable reference to allow CSRF token to also be sent in header.
|
||||||
|
*/
|
||||||
|
createCSRFCookie(): Chainable<any>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,39 +51,21 @@ declare global {
|
|||||||
/**
|
/**
|
||||||
* Login user via REST API directly, and pass authentication token to UI via
|
* Login user via REST API directly, and pass authentication token to UI via
|
||||||
* the UI's dsAuthInfo cookie.
|
* 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 email email to login as
|
||||||
* @param password password to login as
|
* @param password password to login as
|
||||||
*/
|
*/
|
||||||
function login(email: string, password: string): void {
|
function login(email: string, password: string): void {
|
||||||
// Cypress doesn't have access to the running application in Node.js.
|
// Create a fake CSRF cookie/token to use in POST
|
||||||
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
|
cy.createCSRFCookie().then((csrfToken: string) => {
|
||||||
// Instead, we'll read our running application's config.json, which contains the configs &
|
// get our REST API's base URL, also needed for POST
|
||||||
// is regenerated at runtime each time the Angular UI application starts up.
|
cy.task('getRestBaseURL').then((baseRestUrl: string) => {
|
||||||
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 {
|
|
||||||
console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + 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, send login POST request including that CSRF token
|
// Now, send login POST request including that CSRF token
|
||||||
cy.request({
|
cy.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: baseRestUrl + '/api/authn/login',
|
url: baseRestUrl + '/api/authn/login',
|
||||||
headers: { 'X-XSRF-TOKEN' : csrfToken},
|
headers: { [XSRF_REQUEST_HEADER]: csrfToken},
|
||||||
form: true, // indicates the body should be form urlencoded
|
form: true, // indicates the body should be form urlencoded
|
||||||
body: { user: email, password: password }
|
body: { user: email, password: password }
|
||||||
}).then((resp) => {
|
}).then((resp) => {
|
||||||
@@ -76,8 +83,87 @@ function login(email: string, password: string): void {
|
|||||||
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
|
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Add as a Cypress command (i.e. assign to 'cy.login')
|
// Add as a Cypress command (i.e. assign to 'cy.login')
|
||||||
Cypress.Commands.add('login', 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 {
|
||||||
|
// Create a fake CSRF cookie/token to use in POST
|
||||||
|
cy.createCSRFCookie().then((csrfToken: string) => {
|
||||||
|
// get our REST API's base URL, also needed for POST
|
||||||
|
cy.task('getRestBaseURL').then((baseRestUrl: string) => {
|
||||||
|
// 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',
|
||||||
|
// Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot"
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
|
||||||
|
},
|
||||||
|
//form: true, // indicates the body should be form urlencoded
|
||||||
|
body: { targetId: uuid, targetType: dsoType },
|
||||||
|
}).then((resp) => {
|
||||||
|
// We expect a 201 (which means statistics event was created)
|
||||||
|
expect(resp.status).to.eq(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
|
||||||
|
Cypress.Commands.add('generateViewEvent', generateViewEvent);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used by tests to generate a random XSRF/CSRF token and save it to
|
||||||
|
* the required XSRF/CSRF cookie for usage when sending POST requests or similar.
|
||||||
|
* The generated CSRF token is returned in a Chainable to allow it to be also sent
|
||||||
|
* in the CSRF HTTP Header.
|
||||||
|
* @returns a Cypress Chainable which can be used to get the generated CSRF Token
|
||||||
|
*/
|
||||||
|
function createCSRFCookie(): Cypress.Chainable {
|
||||||
|
// Generate a new token which is a random UUID
|
||||||
|
const csrfToken: string = uuidv4();
|
||||||
|
|
||||||
|
// Save it to our required cookie
|
||||||
|
cy.task('getRestBaseDomain').then((baseDomain: string) => {
|
||||||
|
// Create a fake CSRF Token. Set it in the required server-side cookie
|
||||||
|
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
|
||||||
|
});
|
||||||
|
|
||||||
|
// return the generated token wrapped in a chainable
|
||||||
|
return cy.wrap(csrfToken);
|
||||||
|
}
|
||||||
|
// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie')
|
||||||
|
Cypress.Commands.add('createCSRFCookie', createCSRFCookie);
|
||||||
|
74
cypress/support/e2e.ts
Normal file
74
cypress/support/e2e.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import all custom Commands (from commands.ts) for all tests
|
||||||
|
import './commands';
|
||||||
|
|
||||||
|
// Import Cypress Axe tools for all tests
|
||||||
|
// https://github.com/component-driven/cypress-axe
|
||||||
|
import 'cypress-axe';
|
||||||
|
import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants';
|
||||||
|
|
||||||
|
// Runs once before all tests
|
||||||
|
before(() => {
|
||||||
|
// 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 URL of our REST API & save to global variable via task
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
cy.task('saveRestBaseURL', baseRestUrl);
|
||||||
|
|
||||||
|
// Find domain of our REST API & save to global variable via task.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
cy.task('saveRestBaseDomain', baseDomain);
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs once before the first test in each "block"
|
||||||
|
beforeEach(() => {
|
||||||
|
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
|
||||||
|
// This just ensures it doesn't get in the way of matching other objects in the page.
|
||||||
|
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
|
||||||
|
|
||||||
|
// Remove any CSRF cookies saved from prior tests
|
||||||
|
cy.clearCookie(DSPACE_XSRF_COOKIE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 'before()' above.
|
||||||
|
const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
|
||||||
|
const FALLBACK_TEST_REST_DOMAIN = 'localhost';
|
||||||
|
|
||||||
|
// 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*$).+/;
|
@@ -1,63 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// This example support/index.js is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import all custom Commands (from commands.ts) for all tests
|
|
||||||
import './commands';
|
|
||||||
|
|
||||||
// Import Cypress Axe tools for all tests
|
|
||||||
// https://github.com/component-driven/cypress-axe
|
|
||||||
import 'cypress-axe';
|
|
||||||
|
|
||||||
// Runs once before the first test in each "block"
|
|
||||||
beforeEach(() => {
|
|
||||||
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
|
|
||||||
// This just ensures it doesn't get in the way of matching other objects in the page.
|
|
||||||
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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(() => {
|
|
||||||
cy.window().then((win) => {
|
|
||||||
win.location.href = 'about:blank';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Global constants used in tests
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
// Community/collection/publication used for view/edit tests
|
|
||||||
export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200';
|
|
||||||
export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4';
|
|
||||||
export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
|
||||||
// Search term (should return results) used in search tests
|
|
||||||
export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test';
|
|
||||||
// Collection used for submission tests
|
|
||||||
export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection';
|
|
||||||
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';
|
|
@@ -6,21 +6,45 @@
|
|||||||
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.
|
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'
|
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build -t dspace/dspace-angular:dspace-7_x .
|
docker build -t dspace/dspace-angular:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
This image is built *automatically* after each commit is made to the `main` branch.
|
This image is built *automatically* after each commit is made to the `main` branch.
|
||||||
|
|
||||||
Admins to our DockerHub repo can manually publish with the following command.
|
Admins to our DockerHub repo can manually publish with the following command.
|
||||||
```
|
```
|
||||||
docker push dspace/dspace-angular:dspace-7_x
|
docker push dspace/dspace-angular:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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:latest-dist .
|
||||||
|
```
|
||||||
|
|
||||||
|
A default/demo version of this image is built *automatically*.
|
||||||
|
|
||||||
|
## 'docker' directory
|
||||||
- docker-compose.yml
|
- 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.
|
- 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
|
- 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
|
## 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
|
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.
|
## 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._
|
_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
|
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
|
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 or sandbox backend
|
||||||
|
(https://demo.dspace.org/server/ or https://sandbox.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
|
## Ingest test data from AIPDIR
|
||||||
|
|
||||||
Create an administrator
|
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
|
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).
|
## 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. Travis will perform CI testing of Angular using Node to drive the tests._
|
_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
|
||||||
```
|
```
|
||||||
|
@@ -14,13 +14,8 @@
|
|||||||
# Therefore, it should be kept in sync with that file
|
# Therefore, it should be kept in sync with that file
|
||||||
version: "3.7"
|
version: "3.7"
|
||||||
|
|
||||||
networks:
|
|
||||||
dspacenet:
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
dspace-cli:
|
dspace-cli:
|
||||||
networks:
|
|
||||||
dspacenet: {}
|
|
||||||
environment:
|
environment:
|
||||||
# This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
# This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
||||||
- LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz
|
- LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz
|
||||||
|
@@ -13,10 +13,16 @@
|
|||||||
#
|
#
|
||||||
# Therefore, it should be kept in sync with that file
|
# Therefore, it should be kept in sync with that file
|
||||||
version: "3.7"
|
version: "3.7"
|
||||||
|
networks:
|
||||||
|
# Default to using network named 'dspacenet' from docker-compose-rest.yml.
|
||||||
|
# Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet")
|
||||||
|
# If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in)
|
||||||
|
default:
|
||||||
|
name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet
|
||||||
|
external: true
|
||||||
services:
|
services:
|
||||||
dspace-cli:
|
dspace-cli:
|
||||||
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}"
|
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
|
||||||
container_name: dspace-cli
|
container_name: dspace-cli
|
||||||
environment:
|
environment:
|
||||||
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.
|
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.
|
||||||
@@ -30,16 +36,12 @@ services:
|
|||||||
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
||||||
solr__P__server: http://dspacesolr:8983/solr
|
solr__P__server: http://dspacesolr:8983/solr
|
||||||
volumes:
|
volumes:
|
||||||
- "assetstore:/dspace/assetstore"
|
# Keep DSpace assetstore directory between reboots
|
||||||
|
- assetstore:/dspace/assetstore
|
||||||
entrypoint: /dspace/bin/dspace
|
entrypoint: /dspace/bin/dspace
|
||||||
command: help
|
command: help
|
||||||
networks:
|
|
||||||
- dspacenet
|
|
||||||
tty: true
|
tty: true
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
assetstore:
|
assetstore:
|
||||||
|
|
||||||
networks:
|
|
||||||
dspacenet:
|
|
||||||
|
@@ -24,17 +24,20 @@ services:
|
|||||||
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
|
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
|
||||||
# dspace.dir, dspace.server.url and dspace.ui.url
|
# dspace.dir, dspace.server.url and dspace.ui.url
|
||||||
dspace__P__dir: /dspace
|
dspace__P__dir: /dspace
|
||||||
dspace__P__server__P__url: http://localhost:8080/server
|
dspace__P__server__P__url: http://127.0.0.1:8080/server
|
||||||
dspace__P__ui__P__url: http://localhost:4000
|
dspace__P__ui__P__url: http://127.0.0.1:4000
|
||||||
# db.url: Ensure we are using the 'dspacedb' image for our database
|
# db.url: Ensure we are using the 'dspacedb' image for our database
|
||||||
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
|
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
|
||||||
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
||||||
solr__P__server: http://dspacesolr:8983/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'
|
||||||
|
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- dspacedb
|
- dspacedb
|
||||||
image: dspace/dspace:dspace-7_x-test
|
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
ports:
|
ports:
|
||||||
- published: 8080
|
- published: 8080
|
||||||
target: 8080
|
target: 8080
|
||||||
@@ -42,8 +45,6 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
volumes:
|
volumes:
|
||||||
- assetstore:/dspace/assetstore
|
- assetstore:/dspace/assetstore
|
||||||
# Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below)
|
|
||||||
- solr_configs:/dspace/solr
|
|
||||||
# Ensure that the database is ready BEFORE starting tomcat
|
# Ensure that the database is ready BEFORE starting tomcat
|
||||||
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
|
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
|
||||||
# 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any)
|
# 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any)
|
||||||
@@ -67,21 +68,18 @@ services:
|
|||||||
PGDATA: /pgdata
|
PGDATA: /pgdata
|
||||||
image: dspace/dspace-postgres-pgcrypto:loadsql
|
image: dspace/dspace-postgres-pgcrypto:loadsql
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
volumes:
|
volumes:
|
||||||
|
# Keep Postgres data directory between reboots
|
||||||
- pgdata:/pgdata
|
- pgdata:/pgdata
|
||||||
# DSpace Solr container
|
# DSpace Solr container
|
||||||
dspacesolr:
|
dspacesolr:
|
||||||
container_name: dspacesolr
|
container_name: dspacesolr
|
||||||
# Uses official Solr image at https://hub.docker.com/_/solr/
|
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
|
||||||
image: solr:8.11-slim
|
|
||||||
# Needs main 'dspace' container to start first to guarantee access to solr_configs
|
|
||||||
depends_on:
|
|
||||||
- dspace
|
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
ports:
|
ports:
|
||||||
- published: 8983
|
- published: 8983
|
||||||
target: 8983
|
target: 8983
|
||||||
@@ -89,9 +87,6 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
working_dir: /var/solr/data
|
working_dir: /var/solr/data
|
||||||
volumes:
|
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
|
# Keep Solr data directory between reboots
|
||||||
- solr_data:/var/solr/data
|
- solr_data:/var/solr/data
|
||||||
# Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr
|
# Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr
|
||||||
@@ -100,14 +95,18 @@ services:
|
|||||||
- '-c'
|
- '-c'
|
||||||
- |
|
- |
|
||||||
init-var-solr
|
init-var-solr
|
||||||
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
precreate-core authority /opt/solr/server/solr/configsets/authority
|
||||||
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
|
cp -r /opt/solr/server/solr/configsets/authority/* authority
|
||||||
precreate-core search /opt/solr/server/solr/configsets/dspace/search
|
precreate-core oai /opt/solr/server/solr/configsets/oai
|
||||||
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
|
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
|
||||||
|
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
|
||||||
|
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
|
||||||
exec solr -f
|
exec solr -f
|
||||||
volumes:
|
volumes:
|
||||||
assetstore:
|
assetstore:
|
||||||
pgdata:
|
pgdata:
|
||||||
solr_data:
|
solr_data:
|
||||||
# Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above)
|
|
||||||
solr_configs:
|
|
40
docker/docker-compose-dist.yml
Normal file
40
docker/docker-compose-dist.yml
Normal file
@@ -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: sandbox.dspace.org
|
||||||
|
DSPACE_REST_PORT: 443
|
||||||
|
DSPACE_REST_NAMESPACE: /server
|
||||||
|
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile.dist
|
||||||
|
networks:
|
||||||
|
dspacenet:
|
||||||
|
ports:
|
||||||
|
- published: 4000
|
||||||
|
target: 4000
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
@@ -39,11 +39,11 @@ services:
|
|||||||
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
|
# 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.
|
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
|
||||||
proxies__P__trusted__P__ipranges: '172.23.0'
|
proxies__P__trusted__P__ipranges: '172.23.0'
|
||||||
image: dspace/dspace:dspace-7_x-test
|
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- dspacedb
|
- dspacedb
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
ports:
|
ports:
|
||||||
- published: 8080
|
- published: 8080
|
||||||
target: 8080
|
target: 8080
|
||||||
@@ -51,8 +51,6 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
volumes:
|
volumes:
|
||||||
- assetstore:/dspace/assetstore
|
- assetstore:/dspace/assetstore
|
||||||
# Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below)
|
|
||||||
- solr_configs:/dspace/solr
|
|
||||||
# Ensure that the database is ready BEFORE starting tomcat
|
# Ensure that the database is ready BEFORE starting tomcat
|
||||||
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
|
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
|
||||||
# 2. Then, run database migration to init database tables
|
# 2. Then, run database migration to init database tables
|
||||||
@@ -69,26 +67,23 @@ services:
|
|||||||
container_name: dspacedb
|
container_name: dspacedb
|
||||||
environment:
|
environment:
|
||||||
PGDATA: /pgdata
|
PGDATA: /pgdata
|
||||||
image: dspace/dspace-postgres-pgcrypto
|
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
ports:
|
ports:
|
||||||
- published: 5432
|
- published: 5432
|
||||||
target: 5432
|
target: 5432
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
volumes:
|
volumes:
|
||||||
|
# Keep Postgres data directory between reboots
|
||||||
- pgdata:/pgdata
|
- pgdata:/pgdata
|
||||||
# DSpace Solr container
|
# DSpace Solr container
|
||||||
dspacesolr:
|
dspacesolr:
|
||||||
container_name: dspacesolr
|
container_name: dspacesolr
|
||||||
# Uses official Solr image at https://hub.docker.com/_/solr/
|
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
|
||||||
image: solr:8.11-slim
|
|
||||||
# Needs main 'dspace' container to start first to guarantee access to solr_configs
|
|
||||||
depends_on:
|
|
||||||
- dspace
|
|
||||||
networks:
|
networks:
|
||||||
dspacenet:
|
- dspacenet
|
||||||
ports:
|
ports:
|
||||||
- published: 8983
|
- published: 8983
|
||||||
target: 8983
|
target: 8983
|
||||||
@@ -96,32 +91,30 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
working_dir: /var/solr/data
|
working_dir: /var/solr/data
|
||||||
volumes:
|
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
|
# Keep Solr data directory between reboots
|
||||||
- solr_data:/var/solr/data
|
- solr_data:/var/solr/data
|
||||||
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
|
# 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
|
# * 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
|
# * Second, copy configsets to this core:
|
||||||
# to the latest configs. If it's a newly created core, this is a no-op.
|
# 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:
|
entrypoint:
|
||||||
- /bin/bash
|
- /bin/bash
|
||||||
- '-c'
|
- '-c'
|
||||||
- |
|
- |
|
||||||
init-var-solr
|
init-var-solr
|
||||||
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
precreate-core authority /opt/solr/server/solr/configsets/authority
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
|
cp -r /opt/solr/server/solr/configsets/authority/* authority
|
||||||
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
|
precreate-core oai /opt/solr/server/solr/configsets/oai
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai
|
cp -r /opt/solr/server/solr/configsets/oai/* oai
|
||||||
precreate-core search /opt/solr/server/solr/configsets/dspace/search
|
precreate-core search /opt/solr/server/solr/configsets/search
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search
|
cp -r /opt/solr/server/solr/configsets/search/* search
|
||||||
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
|
precreate-core statistics /opt/solr/server/solr/configsets/statistics
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics
|
cp -r /opt/solr/server/solr/configsets/statistics/* statistics
|
||||||
|
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
|
||||||
|
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
|
||||||
exec solr -f
|
exec solr -f
|
||||||
volumes:
|
volumes:
|
||||||
assetstore:
|
assetstore:
|
||||||
pgdata:
|
pgdata:
|
||||||
solr_data:
|
solr_data:
|
||||||
# Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above)
|
|
||||||
solr_configs:
|
|
||||||
|
@@ -24,7 +24,7 @@ services:
|
|||||||
DSPACE_REST_HOST: localhost
|
DSPACE_REST_HOST: localhost
|
||||||
DSPACE_REST_PORT: 8080
|
DSPACE_REST_PORT: 8080
|
||||||
DSPACE_REST_NAMESPACE: /server
|
DSPACE_REST_NAMESPACE: /server
|
||||||
image: dspace/dspace-angular:dspace-7_x
|
image: dspace/dspace-angular:${DSPACE_VER:-latest}
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
11
docker/dspace-ui.json
Normal file
11
docker/dspace-ui.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "dspace-ui",
|
||||||
|
"cwd": "/app",
|
||||||
|
"script": "dist/server/main.js",
|
||||||
|
"instances": "max",
|
||||||
|
"exec_mode": "cluster"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@@ -48,7 +48,7 @@ dspace-angular connects to your DSpace installation by using its REST endpoint.
|
|||||||
```yaml
|
```yaml
|
||||||
rest:
|
rest:
|
||||||
ssl: true
|
ssl: true
|
||||||
host: api7.dspace.org
|
host: demo.dspace.org
|
||||||
port: 443
|
port: 443
|
||||||
nameSpace: /server
|
nameSpace: /server
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ rest:
|
|||||||
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
|
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
|
||||||
```
|
```
|
||||||
DSPACE_REST_SSL=true
|
DSPACE_REST_SSL=true
|
||||||
DSPACE_REST_HOST=api7.dspace.org
|
DSPACE_REST_HOST=demo.dspace.org
|
||||||
DSPACE_REST_PORT=443
|
DSPACE_REST_PORT=443
|
||||||
DSPACE_REST_NAMESPACE=/server
|
DSPACE_REST_NAMESPACE=/server
|
||||||
```
|
```
|
||||||
|
@@ -15,7 +15,10 @@ module.exports = function (config) {
|
|||||||
],
|
],
|
||||||
client: {
|
client: {
|
||||||
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
||||||
captureConsole: false
|
captureConsole: false,
|
||||||
|
jasmine: {
|
||||||
|
failSpecWithNoExpectations: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
coverageIstanbulReporter: {
|
coverageIstanbulReporter: {
|
||||||
dir: require('path').join(__dirname, './coverage/dspace-angular'),
|
dir: require('path').join(__dirname, './coverage/dspace-angular'),
|
||||||
|
217
package.json
217
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dspace-angular",
|
"name": "dspace-angular",
|
||||||
"version": "7.4.0",
|
"version": "8.0.0-next",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"config:watch": "nodemon",
|
"config:watch": "nodemon",
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
|
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
|
||||||
"build": "ng build --configuration development",
|
"build": "ng build --configuration development",
|
||||||
"build:stats": "ng build --stats-json",
|
"build:stats": "ng build --stats-json",
|
||||||
"build:prod": "yarn run build:ssr",
|
"build:prod": "cross-env NODE_ENV=production yarn run build:ssr",
|
||||||
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
|
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
|
||||||
"test": "ng test --sourceMap=true --watch=false --configuration test",
|
"test": "ng test --source-map=true --watch=false --configuration test",
|
||||||
"test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"",
|
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
|
||||||
"test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
|
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint-fix": "ng lint --fix=true",
|
"lint-fix": "ng lint --fix=true",
|
||||||
"e2e": "ng e2e",
|
"e2e": "cross-env NODE_ENV=production ng e2e",
|
||||||
"clean:dev:config": "rimraf src/assets/config.json",
|
"clean:dev:config": "rimraf src/assets/config.json",
|
||||||
"clean:coverage": "rimraf coverage",
|
"clean:coverage": "rimraf coverage",
|
||||||
"clean:dist": "rimraf dist",
|
"clean:dist": "rimraf dist",
|
||||||
@@ -30,8 +30,9 @@
|
|||||||
"clean:log": "rimraf *.log*",
|
"clean:log": "rimraf *.log*",
|
||||||
"clean:json": "rimraf *.records.json",
|
"clean:json": "rimraf *.records.json",
|
||||||
"clean:node": "rimraf node_modules",
|
"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: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",
|
"sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
|
||||||
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
|
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
|
||||||
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
|
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
|
||||||
@@ -54,169 +55,157 @@
|
|||||||
"ts-node": "10.2.1"
|
"ts-node": "10.2.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~13.2.6",
|
"@angular/animations": "^15.2.8",
|
||||||
"@angular/cdk": "^13.2.6",
|
"@angular/cdk": "^15.2.8",
|
||||||
"@angular/common": "~13.2.6",
|
"@angular/common": "^15.2.8",
|
||||||
"@angular/compiler": "~13.2.6",
|
"@angular/compiler": "^15.2.8",
|
||||||
"@angular/core": "~13.2.6",
|
"@angular/core": "^15.2.8",
|
||||||
"@angular/forms": "~13.2.6",
|
"@angular/forms": "^15.2.8",
|
||||||
"@angular/localize": "13.2.6",
|
"@angular/localize": "15.2.8",
|
||||||
"@angular/platform-browser": "~13.2.6",
|
"@angular/platform-browser": "^15.2.8",
|
||||||
"@angular/platform-browser-dynamic": "~13.2.6",
|
"@angular/platform-browser-dynamic": "^15.2.8",
|
||||||
"@angular/platform-server": "~13.2.6",
|
"@angular/platform-server": "^15.2.8",
|
||||||
"@angular/router": "~13.2.6",
|
"@angular/router": "^15.2.8",
|
||||||
"@babel/runtime": "^7.17.2",
|
"@babel/runtime": "7.21.0",
|
||||||
"@kolkov/ngx-gallery": "^2.0.1",
|
"@kolkov/ngx-gallery": "^2.0.1",
|
||||||
"@material-ui/core": "^4.11.0",
|
"@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-bootstrap/ng-bootstrap": "^11.0.0",
|
||||||
"@ng-dynamic-forms/core": "^15.0.0",
|
"@ng-dynamic-forms/core": "^15.0.0",
|
||||||
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
|
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
|
||||||
"@ngrx/effects": "^13.0.2",
|
"@ngrx/effects": "^15.4.0",
|
||||||
"@ngrx/router-store": "^13.0.2",
|
"@ngrx/router-store": "^15.4.0",
|
||||||
"@ngrx/store": "^13.0.2",
|
"@ngrx/store": "^15.4.0",
|
||||||
"@nguniversal/express-engine": "^13.0.2",
|
"@nguniversal/express-engine": "^15.2.1",
|
||||||
"@ngx-translate/core": "^13.0.0",
|
"@ngx-translate/core": "^14.0.0",
|
||||||
"@nicky-lenaers/ngx-scroll-to": "^9.0.0",
|
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
"angular-idle-preload": "3.0.0",
|
"angular-idle-preload": "3.0.0",
|
||||||
"angulartics2": "^12.0.0",
|
"angulartics2": "^12.2.0",
|
||||||
"axios": "^0.27.2",
|
"axios": "^1.6.0",
|
||||||
"bootstrap": "4.3.1",
|
"bootstrap": "^4.6.1",
|
||||||
"caniuse-lite": "^1.0.30001165",
|
|
||||||
"cerialize": "0.1.18",
|
"cerialize": "0.1.18",
|
||||||
"cli-progress": "^3.8.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
"colors": "^1.4.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "1.4.5",
|
"cookie-parser": "1.4.6",
|
||||||
"core-js": "^3.7.0",
|
"core-js": "^3.30.1",
|
||||||
"deepmerge": "^4.2.2",
|
"date-fns": "^2.29.3",
|
||||||
"express": "^4.17.1",
|
"date-fns-tz": "^1.3.7",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^5.1.3",
|
"express-rate-limit": "^5.1.3",
|
||||||
"fast-json-patch": "^3.0.0-1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"filesize": "^6.1.0",
|
"filesize": "^6.1.0",
|
||||||
"font-awesome": "4.7.0",
|
|
||||||
"http-proxy-middleware": "^1.0.5",
|
"http-proxy-middleware": "^1.0.5",
|
||||||
"https": "1.0.0",
|
"http-terminator": "^3.2.0",
|
||||||
|
"isbot": "^3.6.10",
|
||||||
"js-cookie": "2.2.1",
|
"js-cookie": "2.2.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json5": "^2.1.3",
|
"json5": "^2.2.3",
|
||||||
"jsonschema": "1.4.0",
|
"jsonschema": "1.4.1",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"klaro": "^0.7.10",
|
"klaro": "^0.7.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"lru-cache": "^7.14.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"markdown-it-mathjax3": "^4.3.1",
|
"markdown-it-mathjax3": "^4.3.2",
|
||||||
"mirador": "^3.3.0",
|
"mirador": "^3.3.0",
|
||||||
"mirador-dl-plugin": "^0.13.0",
|
"mirador-dl-plugin": "^0.13.0",
|
||||||
"mirador-share-plugin": "^0.11.0",
|
"mirador-share-plugin": "^0.11.0",
|
||||||
"moment": "^2.29.4",
|
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"ng-mocks": "^13.1.1",
|
"ng-mocks": "^14.10.0",
|
||||||
"ng2-file-upload": "1.4.0",
|
"ng2-file-upload": "1.4.0",
|
||||||
"ng2-nouislider": "^1.8.3",
|
"ng2-nouislider": "^2.0.0",
|
||||||
"ngx-infinite-scroll": "^10.0.1",
|
"ngx-infinite-scroll": "^15.0.0",
|
||||||
"ngx-moment": "^5.0.0",
|
"ngx-pagination": "6.0.3",
|
||||||
"ngx-pagination": "5.0.0",
|
|
||||||
"ngx-sortablejs": "^11.1.0",
|
"ngx-sortablejs": "^11.1.0",
|
||||||
"ngx-ui-switch": "^11.0.1",
|
"ngx-ui-switch": "^14.1.0",
|
||||||
"nouislider": "^14.6.3",
|
"nouislider": "^15.7.1",
|
||||||
"pem": "1.14.4",
|
"pem": "1.14.7",
|
||||||
"postcss-cli": "^9.1.0",
|
"prop-types": "^15.8.1",
|
||||||
"prop-types": "^15.7.2",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.8.0",
|
||||||
"sanitize-html": "^2.7.2",
|
"sanitize-html": "^2.12.1",
|
||||||
"sortablejs": "1.13.0",
|
"sortablejs": "1.15.0",
|
||||||
"tslib": "^2.0.0",
|
|
||||||
"url-parse": "^1.5.6",
|
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webfontloader": "1.6.28",
|
"webfontloader": "1.6.28",
|
||||||
"zone.js": "~0.11.5"
|
"zone.js": "~0.11.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "~13.1.0",
|
"@angular-builders/custom-webpack": "~15.0.0",
|
||||||
"@angular-devkit/build-angular": "~13.2.6",
|
"@angular-devkit/build-angular": "^15.2.6",
|
||||||
"@angular-eslint/builder": "13.1.0",
|
"@angular-eslint/builder": "15.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "13.1.0",
|
"@angular-eslint/eslint-plugin": "15.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "13.1.0",
|
"@angular-eslint/eslint-plugin-template": "15.2.1",
|
||||||
"@angular-eslint/schematics": "13.1.0",
|
"@angular-eslint/schematics": "15.2.1",
|
||||||
"@angular-eslint/template-parser": "13.1.0",
|
"@angular-eslint/template-parser": "15.2.1",
|
||||||
"@angular/cli": "~13.2.6",
|
"@angular/cli": "^16.0.4",
|
||||||
"@angular/compiler-cli": "~13.2.6",
|
"@angular/compiler-cli": "^15.2.8",
|
||||||
"@angular/language-service": "~13.2.6",
|
"@angular/language-service": "^15.2.8",
|
||||||
"@cypress/schematic": "^1.5.0",
|
"@cypress/schematic": "^1.5.0",
|
||||||
"@fortawesome/fontawesome-free": "^5.5.0",
|
"@fortawesome/fontawesome-free": "^6.4.0",
|
||||||
"@ngrx/store-devtools": "^13.0.2",
|
"@ngrx/store-devtools": "^15.4.0",
|
||||||
"@ngtools/webpack": "^13.2.6",
|
"@ngtools/webpack": "^15.2.6",
|
||||||
"@nguniversal/builders": "^13.0.2",
|
"@nguniversal/builders": "^15.2.1",
|
||||||
"@types/deep-freeze": "0.1.2",
|
"@types/deep-freeze": "0.1.2",
|
||||||
"@types/express": "^4.17.9",
|
"@types/ejs": "^3.1.2",
|
||||||
"@types/file-saver": "^2.0.1",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
"@types/jasminewd2": "~2.0.8",
|
|
||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
"@types/lodash": "^4.14.165",
|
"@types/lodash": "^4.14.194",
|
||||||
"@types/node": "^14.14.9",
|
"@types/node": "^14.14.9",
|
||||||
"@types/sanitize-html": "^2.6.2",
|
"@types/sanitize-html": "^2.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.11.0",
|
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||||
"@typescript-eslint/parser": "5.11.0",
|
"@typescript-eslint/parser": "^5.59.1",
|
||||||
"axe-core": "^4.3.3",
|
"axe-core": "^4.7.2",
|
||||||
"compression-webpack-plugin": "^9.2.0",
|
"compression-webpack-plugin": "^9.2.0",
|
||||||
"copy-webpack-plugin": "^6.4.1",
|
"copy-webpack-plugin": "^6.4.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"css-loader": "^6.2.0",
|
"cypress": "12.17.4",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"cypress-axe": "^1.4.0",
|
||||||
"cssnano": "^5.0.6",
|
|
||||||
"cypress": "9.5.1",
|
|
||||||
"cypress-axe": "^0.14.0",
|
|
||||||
"debug-loader": "^0.0.1",
|
|
||||||
"deep-freeze": "0.0.1",
|
"deep-freeze": "0.0.1",
|
||||||
"dotenv": "^8.2.0",
|
"eslint": "^8.39.0",
|
||||||
"eslint": "^8.2.0",
|
"eslint-plugin-deprecation": "^1.4.1",
|
||||||
"eslint-plugin-deprecation": "^1.3.2",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import-newlines": "^1.3.1",
|
||||||
"eslint-plugin-jsdoc": "^38.0.6",
|
"eslint-plugin-jsdoc": "^45.0.0",
|
||||||
|
"eslint-plugin-jsonc": "^2.6.0",
|
||||||
|
"eslint-plugin-lodash": "^7.4.0",
|
||||||
|
"eslint-plugin-rxjs": "^5.0.3",
|
||||||
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"express-static-gzip": "^2.1.5",
|
"express-static-gzip": "^2.1.7",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.0.3",
|
|
||||||
"html-loader": "^1.3.2",
|
|
||||||
"jasmine-core": "^3.8.0",
|
"jasmine-core": "^3.8.0",
|
||||||
"jasmine-marbles": "0.9.2",
|
"jasmine-marbles": "0.9.2",
|
||||||
"jasmine-spec-reporter": "~5.0.0",
|
"karma": "^6.4.2",
|
||||||
"karma": "^6.3.14",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-coverage-istanbul-reporter": "~3.0.3",
|
||||||
"karma-coverage-istanbul-reporter": "~3.0.2",
|
|
||||||
"karma-jasmine": "~4.0.0",
|
"karma-jasmine": "~4.0.0",
|
||||||
"karma-jasmine-html-reporter": "^1.5.0",
|
"karma-jasmine-html-reporter": "^1.5.0",
|
||||||
"karma-mocha-reporter": "2.2.5",
|
"karma-mocha-reporter": "2.2.5",
|
||||||
"ngx-mask": "^13.1.7",
|
"ngx-mask": "^13.1.7",
|
||||||
"nodemon": "^2.0.15",
|
"nodemon": "^2.0.22",
|
||||||
"postcss": "^8.1",
|
"postcss": "^8.4",
|
||||||
"postcss-apply": "0.12.0",
|
"postcss-apply": "0.12.0",
|
||||||
"postcss-import": "^14.0.0",
|
"postcss-import": "^14.0.0",
|
||||||
"postcss-loader": "^4.0.3",
|
"postcss-loader": "^4.0.3",
|
||||||
"postcss-preset-env": "^7.4.2",
|
"postcss-preset-env": "^7.4.2",
|
||||||
"postcss-responsive-type": "1.0.0",
|
"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": "^16.14.0",
|
||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs-spy": "^8.0.2",
|
"rxjs-spy": "^8.0.2",
|
||||||
"sass": "~1.32.6",
|
"sass": "~1.62.0",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^12.6.0",
|
||||||
"sass-resources-loader": "^2.1.1",
|
"sass-resources-loader": "^2.2.5",
|
||||||
"string-replace-loader": "^3.1.0",
|
|
||||||
"terser-webpack-plugin": "^2.3.1",
|
|
||||||
"ts-loader": "^5.2.0",
|
|
||||||
"ts-node": "^8.10.2",
|
"ts-node": "^8.10.2",
|
||||||
"typescript": "~4.5.5",
|
"typescript": "~4.8.4",
|
||||||
"webpack": "^5.69.1",
|
"webpack": "5.76.1",
|
||||||
"webpack-bundle-analyzer": "^4.4.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
"webpack-cli": "^4.2.0",
|
"webpack-cli": "^4.2.0",
|
||||||
"webpack-dev-server": "^4.5.0"
|
"webpack-dev-server": "^4.13.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as fs from 'fs';
|
import { existsSync, writeFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AppConfig } from '../src/config/app-config.interface';
|
import { AppConfig } from '../src/config/app-config.interface';
|
||||||
@@ -16,7 +16,7 @@ const appConfig: AppConfig = buildAppConfig();
|
|||||||
|
|
||||||
const angularJsonPath = join(process.cwd(), 'angular.json');
|
const angularJsonPath = join(process.cwd(), 'angular.json');
|
||||||
|
|
||||||
if (!fs.existsSync(angularJsonPath)) {
|
if (!existsSync(angularJsonPath)) {
|
||||||
console.error(`Error:\n${angularJsonPath} does not exist\n`);
|
console.error(`Error:\n${angularJsonPath} does not exist\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ try {
|
|||||||
|
|
||||||
angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref;
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import * as fs from 'fs';
|
import { existsSync, writeFileSync } from 'fs';
|
||||||
import * as yaml from 'js-yaml';
|
import { dump } from 'js-yaml';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +18,7 @@ if (args[0] === undefined) {
|
|||||||
|
|
||||||
const envFullPath = join(process.cwd(), args[0]);
|
const envFullPath = join(process.cwd(), args[0]);
|
||||||
|
|
||||||
if (!fs.existsSync(envFullPath)) {
|
if (!existsSync(envFullPath)) {
|
||||||
console.error(`Error:\n${envFullPath} does not exist\n`);
|
console.error(`Error:\n${envFullPath} does not exist\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -26,10 +26,10 @@ if (!fs.existsSync(envFullPath)) {
|
|||||||
try {
|
try {
|
||||||
const env = require(envFullPath).environment;
|
const env = require(envFullPath).environment;
|
||||||
|
|
||||||
const config = yaml.dump(env);
|
const config = dump(env);
|
||||||
if (args[1]) {
|
if (args[1]) {
|
||||||
const ymlFullPath = join(process.cwd(), args[1]);
|
const ymlFullPath = join(process.cwd(), args[1]);
|
||||||
fs.writeFileSync(ymlFullPath, config);
|
writeFileSync(ymlFullPath, config);
|
||||||
} else {
|
} else {
|
||||||
console.log(config);
|
console.log(config);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import * as child from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import { AppConfig } from '../src/config/app-config.interface';
|
import { AppConfig } from '../src/config/app-config.interface';
|
||||||
import { buildAppConfig } from '../src/config/config.server';
|
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
|
* 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.
|
* 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`,
|
`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 }
|
{ stdio: 'inherit', shell: true }
|
||||||
);
|
);
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import * as http from 'http';
|
import { request } from 'http';
|
||||||
import * as https from 'https';
|
import { request as https_request } from 'https';
|
||||||
|
|
||||||
import { AppConfig } from '../src/config/app-config.interface';
|
import { AppConfig } from '../src/config/app-config.interface';
|
||||||
import { buildAppConfig } from '../src/config/config.server';
|
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 SSL enabled, test via HTTPS, else via HTTP
|
||||||
if (appConfig.rest.ssl) {
|
if (appConfig.rest.ssl) {
|
||||||
const req = https.request(restUrl, (res) => {
|
const req = https_request(restUrl, (res) => {
|
||||||
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
|
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
|
||||||
// We will keep reading data until the 'end' event fires.
|
// We will keep reading data until the 'end' event fires.
|
||||||
// This ensures we don't just read the first chunk.
|
// This ensures we don't just read the first chunk.
|
||||||
@@ -39,7 +39,7 @@ if (appConfig.rest.ssl) {
|
|||||||
|
|
||||||
req.end();
|
req.end();
|
||||||
} else {
|
} else {
|
||||||
const req = http.request(restUrl, (res) => {
|
const req = request(restUrl, (res) => {
|
||||||
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
|
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
|
||||||
// We will keep reading data until the 'end' event fires.
|
// We will keep reading data until the 'end' event fires.
|
||||||
// This ensures we don't just read the first chunk.
|
// This ensures we don't just read the first chunk.
|
||||||
|
@@ -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' });
|
|
412
server.ts
412
server.ts
@@ -19,19 +19,24 @@ import 'zone.js/node';
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import 'rxjs';
|
import 'rxjs';
|
||||||
|
|
||||||
import axios from 'axios';
|
/* eslint-disable import/no-namespace */
|
||||||
import * as pem from 'pem';
|
|
||||||
import * as https from 'https';
|
|
||||||
import * as morgan from 'morgan';
|
import * as morgan from 'morgan';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as ejs from 'ejs';
|
||||||
import * as compression from 'compression';
|
import * as compression from 'compression';
|
||||||
import * as expressStaticGzip from 'express-static-gzip';
|
import * as expressStaticGzip from 'express-static-gzip';
|
||||||
|
/* eslint-enable import/no-namespace */
|
||||||
|
import axios from 'axios';
|
||||||
|
import LRU from 'lru-cache';
|
||||||
|
import isbot from 'isbot';
|
||||||
|
import { createCertificate } from 'pem';
|
||||||
|
import { createServer } from 'https';
|
||||||
|
import { json } from 'body-parser';
|
||||||
|
import { createHttpTerminator } from 'http-terminator';
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
|
||||||
import { enableProdMode } from '@angular/core';
|
import { enableProdMode } from '@angular/core';
|
||||||
|
|
||||||
import { ngExpressEngine } from '@nguniversal/express-engine';
|
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||||
@@ -49,6 +54,8 @@ import { buildAppConfig } from './src/config/config.server';
|
|||||||
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
|
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
|
||||||
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
||||||
import { logStartupMessage } from './startup-message';
|
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
|
* Set path for the browser application's dist folder
|
||||||
@@ -57,12 +64,18 @@ const DIST_FOLDER = join(process.cwd(), 'dist/browser');
|
|||||||
// Set path fir IIIF viewer.
|
// Set path fir IIIF viewer.
|
||||||
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
|
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 cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
|
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<string, any>;
|
||||||
|
|
||||||
|
// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
|
||||||
|
let anonymousCache: LRU<string, any>;
|
||||||
|
|
||||||
// extend environment with app config for server
|
// extend environment with app config for server
|
||||||
extendEnvironmentWithAppConfig(environment, appConfig);
|
extendEnvironmentWithAppConfig(environment, appConfig);
|
||||||
|
|
||||||
@@ -83,10 +96,12 @@ export function app() {
|
|||||||
/*
|
/*
|
||||||
* If production mode is enabled in the environment file:
|
* If production mode is enabled in the environment file:
|
||||||
* - Enable Angular's production mode
|
* - 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)
|
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
|
||||||
*/
|
*/
|
||||||
if (environment.production) {
|
if (environment.production) {
|
||||||
enableProdMode();
|
enableProdMode();
|
||||||
|
initCache();
|
||||||
server.use(compression({
|
server.use(compression({
|
||||||
// only compress responses we've marked as SSR
|
// only compress responses we've marked as SSR
|
||||||
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
||||||
@@ -102,15 +117,15 @@ export function app() {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Add cookie parser middleware
|
* Add cookie parser middleware
|
||||||
* See [morgan](https://github.com/expressjs/cookie-parser)
|
* See [cookie-parser](https://github.com/expressjs/cookie-parser)
|
||||||
*/
|
*/
|
||||||
server.use(cookieParser());
|
server.use(cookieParser());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Add parser for request bodies
|
* Add JSON parser for request bodies
|
||||||
* See [morgan](https://github.com/expressjs/body-parser)
|
* 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)
|
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
|
||||||
server.engine('html', (_, options, callback) =>
|
server.engine('html', (_, options, callback) =>
|
||||||
@@ -133,10 +148,23 @@ export function app() {
|
|||||||
})(_, (options as any), callback)
|
})(_, (options as any), callback)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.engine('ejs', ejs.renderFile);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Register the view engines for html and ejs
|
* Register the view engines for html and ejs
|
||||||
*/
|
*/
|
||||||
server.set('view engine', 'html');
|
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
|
* Set views folder path to directory where template files are stored
|
||||||
@@ -152,6 +180,15 @@ export function app() {
|
|||||||
changeOrigin: true
|
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
|
* 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.
|
* When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled.
|
||||||
@@ -169,7 +206,7 @@ export function app() {
|
|||||||
* Serve static resources (images, i18n messages, …)
|
* Serve static resources (images, i18n messages, …)
|
||||||
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
|
* 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,
|
index: false,
|
||||||
enableBrotli: true,
|
enableBrotli: true,
|
||||||
orderPreference: ['br', 'gzip'],
|
orderPreference: ['br', 'gzip'],
|
||||||
@@ -185,8 +222,11 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
server.get('/app/health', healthCheck);
|
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);
|
server.use(environment.ui.nameSpace, router);
|
||||||
|
|
||||||
@@ -198,60 +238,281 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
function ngApp(req, res) {
|
function ngApp(req, res) {
|
||||||
if (environment.universal.preboot) {
|
if (environment.universal.preboot) {
|
||||||
res.render(indexHtml, {
|
// Render the page to user via SSR (server side rendering)
|
||||||
req,
|
serverSideRender(req, res);
|
||||||
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
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// If preboot is disabled, just serve the client
|
// If preboot is disabled, just serve the client
|
||||||
console.log('Universal off, serving for direct CSR');
|
console.log('Universal off, serving for direct client-side rendering (CSR)');
|
||||||
res.render(indexHtml, {
|
clientSideRender(req, res);
|
||||||
req,
|
}
|
||||||
providers: [{
|
}
|
||||||
provide: APP_BASE_HREF,
|
|
||||||
useValue: req.baseUrl
|
/**
|
||||||
}]
|
* 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 (defined in default-app-config.ts)
|
||||||
|
botCache = new LRU( {
|
||||||
|
max: environment.cache.serverSide.botCache.max,
|
||||||
|
ttl: environment.cache.serverSide.botCache.timeToLive,
|
||||||
|
allowStale: environment.cache.serverSide.botCache.allowStale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anonymousCacheEnabled()) {
|
||||||
|
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
|
||||||
|
// may expire pages more frequently.
|
||||||
|
// When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts)
|
||||||
|
// to minimize anonymous users seeing out-of-date content
|
||||||
|
anonymousCache = new LRU( {
|
||||||
|
max: environment.cache.serverSide.anonymousCache.max,
|
||||||
|
ttl: environment.cache.serverSide.anonymousCache.timeToLive,
|
||||||
|
allowStale: environment.cache.serverSide.anonymousCache.allowStale
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* Adds a cache control header to the response
|
* Return whether bot-specific server side caching is enabled in configuration.
|
||||||
* The cache control value can be configured in the environments file and defaults to max-age=60
|
|
||||||
*/
|
*/
|
||||||
function cacheControl(req, res, next) {
|
function botCacheEnabled(): boolean {
|
||||||
// instruct browser to revalidate
|
// Caching is only enabled if SSR is enabled AND
|
||||||
res.header('Cache-Control', environment.cache.control || 'max-age=60');
|
// "max" pages to cache is greater than zero
|
||||||
next();
|
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<string, any>, 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,23 +527,46 @@ function serverStarted() {
|
|||||||
* @param keys SSL credentials
|
* @param keys SSL credentials
|
||||||
*/
|
*/
|
||||||
function createHttpsServer(keys) {
|
function createHttpsServer(keys) {
|
||||||
https.createServer({
|
const listener = createServer({
|
||||||
key: keys.serviceKey,
|
key: keys.serviceKey,
|
||||||
cert: keys.certificate
|
cert: keys.certificate
|
||||||
}, app).listen(environment.ui.port, environment.ui.host, () => {
|
}, app).listen(environment.ui.port, environment.ui.host, () => {
|
||||||
serverStarted();
|
serverStarted();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown when signalled
|
||||||
|
const terminator = createHttpTerminator({server: listener});
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void (async ()=> {
|
||||||
|
console.debug('Closing HTTPS server on signal');
|
||||||
|
await terminator.terminate().catch(e => { console.error(e); });
|
||||||
|
console.debug('HTTPS server closed');
|
||||||
|
})();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an HTTP server with the configured port and host.
|
||||||
|
*/
|
||||||
function run() {
|
function run() {
|
||||||
const port = environment.ui.port || 4000;
|
const port = environment.ui.port || 4000;
|
||||||
const host = environment.ui.host || '/';
|
const host = environment.ui.host || '/';
|
||||||
|
|
||||||
// Start up the Node server
|
// Start up the Node server
|
||||||
const server = app();
|
const server = app();
|
||||||
server.listen(port, host, () => {
|
const listener = server.listen(port, host, () => {
|
||||||
serverStarted();
|
serverStarted();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown when signalled
|
||||||
|
const terminator = createHttpTerminator({server: listener});
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void (async () => {
|
||||||
|
console.debug('Closing HTTP server on signal');
|
||||||
|
await terminator.terminate().catch(e => { console.error(e); });
|
||||||
|
console.debug('HTTP server closed.');return undefined;
|
||||||
|
})();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
@@ -320,7 +604,7 @@ function start() {
|
|||||||
|
|
||||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation]
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation]
|
||||||
|
|
||||||
pem.createCertificate({
|
createCertificate({
|
||||||
days: 1,
|
days: 1,
|
||||||
selfSigned: true
|
selfSigned: true
|
||||||
}, (error, keys) => {
|
}, (error, keys) => {
|
||||||
|
@@ -1,12 +1,22 @@
|
|||||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
|
||||||
import { getAccessControlModuleRoute } from '../app-routing-paths';
|
import { getAccessControlModuleRoute } from '../app-routing-paths';
|
||||||
|
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||||
|
|
||||||
export const GROUP_EDIT_PATH = 'groups';
|
export const EPERSON_PATH = 'epeople';
|
||||||
|
|
||||||
|
export function getEPersonsRoute(): string {
|
||||||
|
return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEPersonEditRoute(id: string): string {
|
||||||
|
return new URLCombiner(getEPersonsRoute(), id, 'edit').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_PATH = 'groups';
|
||||||
|
|
||||||
export function getGroupsRoute() {
|
export function getGroupsRoute() {
|
||||||
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString();
|
return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGroupEditRoute(id: string) {
|
export function getGroupEditRoute(id: string) {
|
||||||
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString();
|
return new URLCombiner(getGroupsRoute(), id, 'edit').toString();
|
||||||
}
|
}
|
||||||
|
@@ -1,55 +1,90 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
|
||||||
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
|
||||||
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
|
||||||
import { GROUP_EDIT_PATH } from './access-control-routing-paths';
|
|
||||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
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 { 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 { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
|
||||||
|
import {
|
||||||
|
EPERSON_PATH,
|
||||||
|
GROUP_PATH,
|
||||||
|
} from './access-control-routing-paths';
|
||||||
|
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
|
||||||
|
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||||
|
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
||||||
|
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
|
||||||
|
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||||
|
import { GroupPageGuard } from './group-registry/group-page.guard';
|
||||||
|
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
{
|
{
|
||||||
path: 'epeople',
|
path: EPERSON_PATH,
|
||||||
component: EPeopleRegistryComponent,
|
component: EPeopleRegistryComponent,
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: I18nBreadcrumbResolver
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
|
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
|
||||||
canActivate: [SiteAdministratorGuard]
|
canActivate: [SiteAdministratorGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: GROUP_EDIT_PATH,
|
path: `${EPERSON_PATH}/create`,
|
||||||
|
component: EPersonFormComponent,
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
|
},
|
||||||
|
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
|
||||||
|
canActivate: [SiteAdministratorGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${EPERSON_PATH}/:id/edit`,
|
||||||
|
component: EPersonFormComponent,
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
|
ePerson: EPersonResolver,
|
||||||
|
},
|
||||||
|
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
|
||||||
|
canActivate: [SiteAdministratorGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: GROUP_PATH,
|
||||||
component: GroupsRegistryComponent,
|
component: GroupsRegistryComponent,
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: I18nBreadcrumbResolver
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
|
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
|
||||||
canActivate: [GroupAdministratorGuard]
|
canActivate: [GroupAdministratorGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `${GROUP_EDIT_PATH}/newGroup`,
|
path: `${GROUP_PATH}/create`,
|
||||||
component: GroupFormComponent,
|
component: GroupFormComponent,
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: I18nBreadcrumbResolver
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
|
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
|
||||||
canActivate: [GroupAdministratorGuard]
|
canActivate: [GroupAdministratorGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `${GROUP_EDIT_PATH}/:groupId`,
|
path: `${GROUP_PATH}/:groupId/edit`,
|
||||||
component: GroupFormComponent,
|
component: GroupFormComponent,
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: I18nBreadcrumbResolver
|
breadcrumb: I18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
|
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
|
||||||
canActivate: [GroupPageGuard]
|
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],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* Routing module for the AccessControl section of the admin sidebar
|
* Routing module for the AccessControl section of the admin sidebar
|
||||||
|
@@ -1,15 +1,35 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { AbstractControl } from '@angular/forms';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import {
|
||||||
|
DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||||
|
DynamicErrorMessagesMatcher,
|
||||||
|
} from '@ng-dynamic-forms/core';
|
||||||
|
|
||||||
|
import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';
|
||||||
|
import { FormModule } from '../shared/form/form.module';
|
||||||
|
import { SearchModule } from '../shared/search/search.module';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
import { SharedModule } from '../shared/shared.module';
|
||||||
import { AccessControlRoutingModule } from './access-control-routing.module';
|
import { AccessControlRoutingModule } from './access-control-routing.module';
|
||||||
|
import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
|
||||||
|
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
|
||||||
|
import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||||
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
||||||
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||||
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
|
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
|
||||||
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
|
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
|
||||||
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||||
import { FormModule } from '../shared/form/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({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,7 +37,13 @@ import { FormModule } from '../shared/form/form.module';
|
|||||||
SharedModule,
|
SharedModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
AccessControlRoutingModule,
|
AccessControlRoutingModule,
|
||||||
FormModule
|
FormModule,
|
||||||
|
NgbAccordionModule,
|
||||||
|
SearchModule,
|
||||||
|
AccessControlFormModule,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
MembersListComponent,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
EPeopleRegistryComponent,
|
EPeopleRegistryComponent,
|
||||||
@@ -25,8 +51,17 @@ import { FormModule } from '../shared/form/form.module';
|
|||||||
GroupsRegistryComponent,
|
GroupsRegistryComponent,
|
||||||
GroupFormComponent,
|
GroupFormComponent,
|
||||||
SubgroupsListComponent,
|
SubgroupsListComponent,
|
||||||
MembersListComponent
|
MembersListComponent,
|
||||||
]
|
BulkAccessComponent,
|
||||||
|
BulkAccessBrowseComponent,
|
||||||
|
BulkAccessSettingsComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||||
|
useValue: ValidateEmailErrorStateMatcher,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* This module handles all components related to the access control pages
|
* This module handles all components related to the access control pages
|
||||||
|
@@ -0,0 +1,69 @@
|
|||||||
|
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
|
||||||
|
<ngb-panel [id]="'browse'">
|
||||||
|
<ng-template ngbPanelHeader>
|
||||||
|
<div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')"
|
||||||
|
data-test="browse">
|
||||||
|
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
|
||||||
|
[attr.aria-expanded]="acc.isExpanded('browse')"
|
||||||
|
aria-controls="bulk-access-browse-panel-content">
|
||||||
|
{{ 'admin.access-control.bulk-access-browse.header' | translate }}
|
||||||
|
</button>
|
||||||
|
<div class="text-right d-flex gap-2">
|
||||||
|
<div class="d-flex my-auto">
|
||||||
|
<span *ngIf="acc.isExpanded('browse')" class="fas fa-chevron-up fa-fw"></span>
|
||||||
|
<span *ngIf="!acc.isExpanded('browse')" class="fas fa-chevron-down fa-fw"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template ngbPanelContent>
|
||||||
|
<div id="bulk-access-browse-panel-content">
|
||||||
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills">
|
||||||
|
<li [ngbNavItem]="'search'" role="presentation">
|
||||||
|
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="mx-n3">
|
||||||
|
<ds-themed-search [configuration]="'administrativeBulkAccess'"
|
||||||
|
[selectable]="true"
|
||||||
|
[selectionConfig]="{ repeatable: true, listId: listId }"
|
||||||
|
[showThumbnails]="false"></ds-themed-search>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
<li [ngbNavItem]="'selected'" role="presentation">
|
||||||
|
<a ngbNavLink>
|
||||||
|
{{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }}
|
||||||
|
</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<ds-pagination
|
||||||
|
[paginationOptions]="(paginationOptions$ | async)"
|
||||||
|
[pageInfoState]="(objectsSelected$|async)?.payload.pageInfo"
|
||||||
|
[collectionSize]="(objectsSelected$|async)?.payload?.totalElements"
|
||||||
|
[objects]="(objectsSelected$|async)"
|
||||||
|
[showPaginator]="false"
|
||||||
|
(prev)="pagePrev()"
|
||||||
|
(next)="pageNext()">
|
||||||
|
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4">
|
||||||
|
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
|
||||||
|
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last '
|
||||||
|
class="mt-4 mb-4 d-flex"
|
||||||
|
[attr.data-test]="'list-object' | dsBrowserOnly">
|
||||||
|
<ds-selectable-list-item-control [index]="i"
|
||||||
|
[object]="object"
|
||||||
|
[selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control>
|
||||||
|
<ds-listable-object-component-loader [listID]="listId"
|
||||||
|
[index]="i"
|
||||||
|
[object]="object"
|
||||||
|
[showThumbnails]="false"
|
||||||
|
[viewMode]="'list'"></ds-listable-object-component-loader>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ds-pagination>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div [ngbNavOutlet]="nav" class="mt-5"></div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</ngb-panel>
|
||||||
|
</ngb-accordion>
|
@@ -0,0 +1,88 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
NgbAccordionModule,
|
||||||
|
NgbNavModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
|
||||||
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
|
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
|
||||||
|
import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec';
|
||||||
|
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
|
||||||
|
import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
|
||||||
|
|
||||||
|
describe('BulkAccessBrowseComponent', () => {
|
||||||
|
let component: BulkAccessBrowseComponent;
|
||||||
|
let fixture: ComponentFixture<BulkAccessBrowseComponent>;
|
||||||
|
|
||||||
|
const listID1 = 'id1';
|
||||||
|
const value1 = 'Selected object';
|
||||||
|
const value2 = 'Another selected object';
|
||||||
|
|
||||||
|
const selected1 = new SelectableObject(value1);
|
||||||
|
const selected2 = new SelectableObject(value2);
|
||||||
|
|
||||||
|
const testSelection = { id: listID1, selection: [selected1, selected2] } ;
|
||||||
|
|
||||||
|
const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']);
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgbAccordionModule,
|
||||||
|
NgbNavModule,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
declarations: [BulkAccessBrowseComponent],
|
||||||
|
providers: [ { provide: SelectableListService, useValue: selectableListService } ],
|
||||||
|
schemas: [
|
||||||
|
NO_ERRORS_SCHEMA,
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BulkAccessBrowseComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(testSelection));
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.destroy();
|
||||||
|
component = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the component', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have an initial active nav id of "search"', () => {
|
||||||
|
expect(component.activateId).toEqual('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have an initial pagination options object with default values', () => {
|
||||||
|
expect(component.paginationOptions$.getValue().id).toEqual('bas');
|
||||||
|
expect(component.paginationOptions$.getValue().pageSize).toEqual(5);
|
||||||
|
expect(component.paginationOptions$.getValue().currentPage).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have an initial remote data with a paginated list as value', () => {
|
||||||
|
const list = buildPaginatedList(new PageInfo({
|
||||||
|
'elementsPerPage': 5,
|
||||||
|
'totalElements': 2,
|
||||||
|
'totalPages': 1,
|
||||||
|
'currentPage': 1,
|
||||||
|
}), [selected1, selected2]) ;
|
||||||
|
const rd = createSuccessfulRemoteDataObject(list);
|
||||||
|
|
||||||
|
expect(component.objectsSelected$.value).toEqual(rd);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Subscription,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPaginatedList,
|
||||||
|
PaginatedList,
|
||||||
|
} from '../../../core/data/paginated-list.model';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
|
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
|
||||||
|
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
|
||||||
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
|
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
|
||||||
|
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
|
||||||
|
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
|
||||||
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-bulk-access-browse',
|
||||||
|
templateUrl: 'bulk-access-browse.component.html',
|
||||||
|
styleUrls: ['./bulk-access-browse.component.scss'],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: SEARCH_CONFIG_SERVICE,
|
||||||
|
useClass: SearchConfigurationService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection list id
|
||||||
|
*/
|
||||||
|
@Input() listId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active nav id
|
||||||
|
*/
|
||||||
|
activateId = 'search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of the objects already selected
|
||||||
|
*/
|
||||||
|
objectsSelected$: BehaviorSubject<RemoteData<PaginatedList<ListableObject>>> = new BehaviorSubject<RemoteData<PaginatedList<ListableObject>>>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pagination options object used for the list of selected elements
|
||||||
|
*/
|
||||||
|
paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'bas',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||||
|
*/
|
||||||
|
private subs: Subscription[] = [];
|
||||||
|
|
||||||
|
constructor(private selectableListService: SelectableListService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to selectable list updates
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.subs.push(
|
||||||
|
this.selectableListService.getSelectableList(this.listId).pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)),
|
||||||
|
).subscribe(this.objectsSelected$),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNext() {
|
||||||
|
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
|
||||||
|
currentPage: this.paginationOptions$.value.currentPage + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pagePrev() {
|
||||||
|
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
|
||||||
|
currentPage: this.paginationOptions$.value.currentPage - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculatePageCount(pageSize, totalCount = 0) {
|
||||||
|
// we suppose that if we have 0 items we want 1 empty page
|
||||||
|
return totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate The RemoteData object containing the list of the selected elements
|
||||||
|
* @param list
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private generatePaginatedListBySelectedElements(list: SelectableListState): RemoteData<PaginatedList<ListableObject>> {
|
||||||
|
const pageInfo = new PageInfo({
|
||||||
|
elementsPerPage: this.paginationOptions$.value.pageSize,
|
||||||
|
totalElements: list?.selection.length,
|
||||||
|
totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length),
|
||||||
|
currentPage: this.paginationOptions$.value.currentPage,
|
||||||
|
});
|
||||||
|
if (pageInfo.currentPage > pageInfo.totalPages) {
|
||||||
|
pageInfo.currentPage = pageInfo.totalPages;
|
||||||
|
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
|
||||||
|
currentPage: pageInfo.currentPage,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs
|
||||||
|
.filter((sub) => hasValue(sub))
|
||||||
|
.forEach((sub) => sub.unsubscribe());
|
||||||
|
this.selectableListService.deselectAll(this.listId);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1>{{ 'admin.access-control.bulk-access.title' | translate }}</h1>
|
||||||
|
<ds-bulk-access-browse [listId]="listId"></ds-bulk-access-browse>
|
||||||
|
<div class="clearfix mb-3"></div>
|
||||||
|
<ds-bulk-access-settings #dsBulkSettings ></ds-bulk-access-settings>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-primary mr-3" (click)="reset()">
|
||||||
|
{{ 'access-control-cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
|
||||||
|
{{ 'access-control-execute' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
160
src/app/access-control/bulk-access/bulk-access.component.spec.ts
Normal file
160
src/app/access-control/bulk-access/bulk-access.component.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { Process } from '../../process-page/processes/process.model';
|
||||||
|
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
|
||||||
|
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
|
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||||
|
import { BulkAccessComponent } from './bulk-access.component';
|
||||||
|
|
||||||
|
describe('BulkAccessComponent', () => {
|
||||||
|
let component: BulkAccessComponent;
|
||||||
|
let fixture: ComponentFixture<BulkAccessComponent>;
|
||||||
|
let bulkAccessControlService: any;
|
||||||
|
let selectableListService: any;
|
||||||
|
|
||||||
|
const selectableListServiceMock = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']);
|
||||||
|
const bulkAccessControlServiceMock = jasmine.createSpyObj('bulkAccessControlService', ['createPayloadFile', 'executeScript']);
|
||||||
|
|
||||||
|
const mockFormState = {
|
||||||
|
'bitstream': [],
|
||||||
|
'item': [
|
||||||
|
{
|
||||||
|
'name': 'embargo',
|
||||||
|
'startDate': {
|
||||||
|
'year': 2026,
|
||||||
|
'month': 5,
|
||||||
|
'day': 31,
|
||||||
|
},
|
||||||
|
'endDate': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'state': {
|
||||||
|
'item': {
|
||||||
|
'toggleStatus': true,
|
||||||
|
'accessMode': 'replace',
|
||||||
|
},
|
||||||
|
'bitstream': {
|
||||||
|
'toggleStatus': false,
|
||||||
|
'accessMode': '',
|
||||||
|
'changesLimit': '',
|
||||||
|
'selectedBitstreams': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFile = {
|
||||||
|
'uuids': [
|
||||||
|
'1234', '5678',
|
||||||
|
],
|
||||||
|
'file': { },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
|
||||||
|
getValue: jasmine.createSpy('getValue'),
|
||||||
|
reset: jasmine.createSpy('reset'),
|
||||||
|
});
|
||||||
|
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
|
||||||
|
const selectableListState: SelectableListState = { id: 'test', selection };
|
||||||
|
const expectedIdList = ['1234', '5678'];
|
||||||
|
|
||||||
|
const selectableListStateEmpty: SelectableListState = { id: 'test', selection: [] };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RouterTestingModule,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
declarations: [ BulkAccessComponent ],
|
||||||
|
providers: [
|
||||||
|
{ provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock },
|
||||||
|
{ provide: NotificationsService, useValue: NotificationsServiceStub },
|
||||||
|
{ provide: SelectableListService, useValue: selectableListServiceMock },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BulkAccessComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
bulkAccessControlService = TestBed.inject(BulkAccessControlService);
|
||||||
|
selectableListService = TestBed.inject(SelectableListService);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when there are no elements selected', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty));
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.settings = mockSettings;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the id list by selected elements', () => {
|
||||||
|
expect(component.objectsSelected$.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable the execute button when there are no objects selected', () => {
|
||||||
|
expect(component.canExport()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when there are elements selected', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.settings = mockSettings;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the id list by selected elements', () => {
|
||||||
|
expect(component.objectsSelected$.value).toEqual(expectedIdList);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable the execute button when there are objects selected', () => {
|
||||||
|
component.objectsSelected$.next(['1234']);
|
||||||
|
expect(component.canExport()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the settings reset method when reset is called', () => {
|
||||||
|
component.reset();
|
||||||
|
expect(component.settings.reset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the bulkAccessControlService executeScript method when submit is called', () => {
|
||||||
|
(component.settings as any).getValue.and.returnValue(mockFormState);
|
||||||
|
bulkAccessControlService.createPayloadFile.and.returnValue(mockFile);
|
||||||
|
bulkAccessControlService.executeScript.and.returnValue(createSuccessfulRemoteDataObject$(new Process()));
|
||||||
|
component.objectsSelected$.next(['1234']);
|
||||||
|
component.submit();
|
||||||
|
expect(bulkAccessControlService.executeScript).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
103
src/app/access-control/bulk-access/bulk-access.component.ts
Normal file
103
src/app/access-control/bulk-access/bulk-access.component.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Subscription,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
|
||||||
|
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
|
||||||
|
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
|
||||||
|
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-bulk-access',
|
||||||
|
templateUrl: './bulk-access.component.html',
|
||||||
|
styleUrls: ['./bulk-access.component.scss'],
|
||||||
|
})
|
||||||
|
export class BulkAccessComponent implements OnInit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection list id
|
||||||
|
*/
|
||||||
|
listId = 'bulk-access-list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of the objects already selected
|
||||||
|
*/
|
||||||
|
objectsSelected$: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||||
|
*/
|
||||||
|
private subs: Subscription[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SectionsDirective reference
|
||||||
|
*/
|
||||||
|
@ViewChild('dsBulkSettings') settings: BulkAccessSettingsComponent;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private bulkAccessControlService: BulkAccessControlService,
|
||||||
|
private selectableListService: SelectableListService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subs.push(
|
||||||
|
this.selectableListService.getSelectableList(this.listId).pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((list: SelectableListState) => this.generateIdListBySelectedElements(list)),
|
||||||
|
).subscribe(this.objectsSelected$),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
canExport(): boolean {
|
||||||
|
return this.objectsSelected$.value?.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the form to its initial state
|
||||||
|
* This will also reset the state of the child components (bitstream and item access)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.settings.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the form
|
||||||
|
* This will create a payload file and execute the script
|
||||||
|
*/
|
||||||
|
submit(): void {
|
||||||
|
const settings = this.settings.getValue();
|
||||||
|
const bitstreamAccess = settings.bitstream;
|
||||||
|
const itemAccess = settings.item;
|
||||||
|
|
||||||
|
const { file } = this.bulkAccessControlService.createPayloadFile({
|
||||||
|
bitstreamAccess,
|
||||||
|
itemAccess,
|
||||||
|
state: settings.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bulkAccessControlService.executeScript(
|
||||||
|
this.objectsSelected$.value || [],
|
||||||
|
file,
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate The RemoteData object containing the list of the selected elements
|
||||||
|
* @param list
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private generateIdListBySelectedElements(list: SelectableListState): string[] {
|
||||||
|
return list?.selection?.map((entry: any) => entry.indexableObject.uuid);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,21 @@
|
|||||||
|
<ngb-accordion #acc="ngbAccordion" [activeIds]="'settings'">
|
||||||
|
<ngb-panel [id]="'settings'">
|
||||||
|
<ng-template ngbPanelHeader>
|
||||||
|
<div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('settings')" data-test="settings">
|
||||||
|
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()" [attr.aria-expanded]="acc.isExpanded('settings')"
|
||||||
|
aria-controls="bulk-access-settings-panel-content">
|
||||||
|
{{ 'admin.access-control.bulk-access-settings.header' | translate }}
|
||||||
|
</button>
|
||||||
|
<div class="text-right d-flex gap-2">
|
||||||
|
<div class="d-flex my-auto">
|
||||||
|
<span *ngIf="acc.isExpanded('settings')" class="fas fa-chevron-up fa-fw"></span>
|
||||||
|
<span *ngIf="!acc.isExpanded('settings')" class="fas fa-chevron-down fa-fw"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template ngbPanelContent>
|
||||||
|
<ds-access-control-form-container id="bulk-access-settings-panel-content" #dsAccessControlForm [showSubmit]="false"></ds-access-control-form-container>
|
||||||
|
</ng-template>
|
||||||
|
</ngb-panel>
|
||||||
|
</ngb-accordion>
|
@@ -0,0 +1,85 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { BulkAccessSettingsComponent } from './bulk-access-settings.component';
|
||||||
|
|
||||||
|
describe('BulkAccessSettingsComponent', () => {
|
||||||
|
let component: BulkAccessSettingsComponent;
|
||||||
|
let fixture: ComponentFixture<BulkAccessSettingsComponent>;
|
||||||
|
const mockFormState = {
|
||||||
|
'bitstream': [],
|
||||||
|
'item': [
|
||||||
|
{
|
||||||
|
'name': 'embargo',
|
||||||
|
'startDate': {
|
||||||
|
'year': 2026,
|
||||||
|
'month': 5,
|
||||||
|
'day': 31,
|
||||||
|
},
|
||||||
|
'endDate': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'state': {
|
||||||
|
'item': {
|
||||||
|
'toggleStatus': true,
|
||||||
|
'accessMode': 'replace',
|
||||||
|
},
|
||||||
|
'bitstream': {
|
||||||
|
'toggleStatus': false,
|
||||||
|
'accessMode': '',
|
||||||
|
'changesLimit': '',
|
||||||
|
'selectedBitstreams': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
|
||||||
|
getFormValue: jasmine.createSpy('getFormValue'),
|
||||||
|
reset: jasmine.createSpy('reset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [NgbAccordionModule, TranslateModule.forRoot()],
|
||||||
|
declarations: [BulkAccessSettingsComponent],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BulkAccessSettingsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.controlForm = mockControl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the component', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a method to get the form value', () => {
|
||||||
|
expect(component.getValue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a method to reset the form', () => {
|
||||||
|
expect(component.reset).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct form value', () => {
|
||||||
|
const expectedValue = mockFormState;
|
||||||
|
(component.controlForm as any).getFormValue.and.returnValue(mockFormState);
|
||||||
|
const actualValue = component.getValue();
|
||||||
|
// @ts-ignore
|
||||||
|
expect(actualValue).toEqual(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call reset on the control form', () => {
|
||||||
|
component.reset();
|
||||||
|
expect(component.controlForm.reset).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-bulk-access-settings',
|
||||||
|
templateUrl: 'bulk-access-settings.component.html',
|
||||||
|
styleUrls: ['./bulk-access-settings.component.scss'],
|
||||||
|
exportAs: 'dsBulkSettings',
|
||||||
|
})
|
||||||
|
export class BulkAccessSettingsComponent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SectionsDirective reference
|
||||||
|
*/
|
||||||
|
@ViewChild('dsAccessControlForm') controlForm: AccessControlFormContainerComponent<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will be used from a parent component to read the value of the form
|
||||||
|
*/
|
||||||
|
getValue() {
|
||||||
|
return this.controlForm.getFormValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the form to its initial state
|
||||||
|
* This will also reset the state of the child components (bitstream and item access)
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.controlForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
import { Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
|
|
||||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||||
import { type } from '../../shared/ngrx/type';
|
import { type } from '../../shared/ngrx/type';
|
||||||
|
|
||||||
|
@@ -2,98 +2,93 @@
|
|||||||
<div class="epeople-registry row">
|
<div class="epeople-registry row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between border-bottom mb-3">
|
<div class="d-flex justify-content-between border-bottom mb-3">
|
||||||
<h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2>
|
<h1 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h1>
|
||||||
|
|
||||||
<div *ngIf="!isEPersonFormShown">
|
<div>
|
||||||
<button class="mr-auto btn btn-success addEPerson-button"
|
<button class="mr-auto btn btn-success addEPerson-button"
|
||||||
(click)="isEPersonFormShown = true">
|
[routerLink]="'create'">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
<span class="d-none d-sm-inline">{{labelPrefix + 'button.add' | translate}}</span>
|
<span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()"
|
<h2 id="search" class="border-bottom pb-2">
|
||||||
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
|
{{labelPrefix + 'search.head' | translate}}
|
||||||
|
</h2>
|
||||||
<div *ngIf="!isEPersonFormShown">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||||
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
|
<div>
|
||||||
|
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||||
</h3>
|
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
<option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
|
||||||
<div>
|
</select>
|
||||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
</div>
|
||||||
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
|
<div class="flex-grow-1 mr-3 ml-3">
|
||||||
<option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
|
<div class="form-group input-group">
|
||||||
</select>
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
</div>
|
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
|
||||||
<div class="flex-grow-1 mr-3 ml-3">
|
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
|
||||||
<div class="form-group input-group">
|
<span class="input-group-append">
|
||||||
<input type="text" name="query" id="query" formControlName="query"
|
|
||||||
class="form-control" attr.aria-label="{{labelPrefix + 'search.placeholder' | translate}}"
|
|
||||||
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
|
|
||||||
<span class="input-group-append">
|
|
||||||
<button type="submit" class="search-button btn btn-primary">
|
<button type="submit" class="search-button btn btn-primary">
|
||||||
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
|
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<button (click)="clearFormAndResetResult();"
|
|
||||||
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
|
|
||||||
<ds-pagination
|
|
||||||
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
|
|
||||||
[paginationOptions]="config"
|
|
||||||
[pageInfoState]="pageInfoState$"
|
|
||||||
[collectionSize]="(pageInfoState$ | async)?.totalElements"
|
|
||||||
[hideGear]="true"
|
|
||||||
[hidePagerWhenSinglePage]="true">
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table id="epeople" class="table table-striped table-hover table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
|
|
||||||
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
|
|
||||||
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
|
|
||||||
<th>{{labelPrefix + 'table.edit' | translate}}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
|
||||||
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
|
||||||
<td>{{epersonDto.eperson.id}}</td>
|
|
||||||
<td>{{epersonDto.eperson.name}}</td>
|
|
||||||
<td>{{epersonDto.eperson.email}}</td>
|
|
||||||
<td>
|
|
||||||
<div class="btn-group edit-field">
|
|
||||||
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)"
|
|
||||||
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
|
||||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
|
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
|
||||||
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
|
||||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
|
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ds-pagination>
|
|
||||||
|
|
||||||
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
|
||||||
{{labelPrefix + 'no-items' | translate}}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button (click)="clearFormAndResetResult();"
|
||||||
|
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (searching$ | async) !== true"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="pageInfoState$"
|
||||||
|
[collectionSize]="(pageInfoState$ | async)?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="epeople" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
|
||||||
|
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
|
||||||
|
<th>{{labelPrefix + 'table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
||||||
|
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
||||||
|
<td>{{epersonDto.eperson.id}}</td>
|
||||||
|
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
|
||||||
|
<td>{{epersonDto.eperson.email}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button [routerLink]="getEditEPeoplePage(epersonDto.eperson.id)"
|
||||||
|
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
||||||
|
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
|
||||||
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
||||||
|
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
||||||
|
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||||
|
{{labelPrefix + 'no-items' | translate}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,47 +1,72 @@
|
|||||||
import { Router } from '@angular/router';
|
|
||||||
import { Observable, of as observableOf } from 'rxjs';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import {
|
||||||
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
DebugElement,
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
NO_ERRORS_SCHEMA,
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
} from '@angular/core';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import {
|
||||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
ComponentFixture,
|
||||||
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
|
fakeAsync,
|
||||||
|
TestBed,
|
||||||
|
tick,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import {
|
||||||
|
BrowserModule,
|
||||||
|
By,
|
||||||
|
} from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
NgbModal,
|
||||||
|
NgbModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
Observable,
|
||||||
|
of as observableOf,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { FindListOptions } from '../../core/data/find-list-options.model';
|
||||||
|
import {
|
||||||
|
buildPaginatedList,
|
||||||
|
PaginatedList,
|
||||||
|
} from '../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { RequestService } from '../../core/data/request.service';
|
||||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||||
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { PageInfo } from '../../core/shared/page-info.model';
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry.component';
|
|
||||||
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
|
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
|
||||||
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
|
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
|
||||||
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
|
import {
|
||||||
|
EPersonMock,
|
||||||
|
EPersonMock2,
|
||||||
|
} from '../../shared/testing/eperson.mock';
|
||||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||||
import { RouterStub } from '../../shared/testing/router.stub';
|
|
||||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
|
||||||
import { RequestService } from '../../core/data/request.service';
|
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
|
||||||
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
||||||
import { FindListOptions } from '../../core/data/find-list-options.model';
|
import { RouterStub } from '../../shared/testing/router.stub';
|
||||||
|
import { EPeopleRegistryComponent } from './epeople-registry.component';
|
||||||
|
|
||||||
describe('EPeopleRegistryComponent', () => {
|
describe('EPeopleRegistryComponent', () => {
|
||||||
let component: EPeopleRegistryComponent;
|
let component: EPeopleRegistryComponent;
|
||||||
let fixture: ComponentFixture<EPeopleRegistryComponent>;
|
let fixture: ComponentFixture<EPeopleRegistryComponent>;
|
||||||
let translateService: TranslateService;
|
|
||||||
let builderService: FormBuilderService;
|
let builderService: FormBuilderService;
|
||||||
|
|
||||||
let mockEPeople;
|
let mockEPeople: EPerson[];
|
||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
let authorizationService: AuthorizationDataService;
|
let authorizationService: AuthorizationDataService;
|
||||||
let modalService;
|
let modalService: NgbModal;
|
||||||
|
let paginationService: PaginationServiceStub;
|
||||||
|
|
||||||
let paginationService;
|
beforeEach(waitForAsync(async () => {
|
||||||
|
jasmine.getEnv().allowRespy(true);
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
mockEPeople = [EPersonMock, EPersonMock2];
|
mockEPeople = [EPersonMock, EPersonMock2];
|
||||||
ePersonDataServiceStub = {
|
ePersonDataServiceStub = {
|
||||||
activeEPerson: null,
|
activeEPerson: null,
|
||||||
@@ -51,7 +76,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
elementsPerPage: this.allEpeople.length,
|
elementsPerPage: this.allEpeople.length,
|
||||||
totalElements: this.allEpeople.length,
|
totalElements: this.allEpeople.length,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
}), this.allEpeople));
|
}), this.allEpeople));
|
||||||
},
|
},
|
||||||
getActiveEPerson(): Observable<EPerson> {
|
getActiveEPerson(): Observable<EPerson> {
|
||||||
@@ -66,7 +91,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
elementsPerPage: [result].length,
|
elementsPerPage: [result].length,
|
||||||
totalElements: [result].length,
|
totalElements: [result].length,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
}), [result]));
|
}), [result]));
|
||||||
}
|
}
|
||||||
if (scope === 'metadata') {
|
if (scope === 'metadata') {
|
||||||
@@ -75,7 +100,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
elementsPerPage: this.allEpeople.length,
|
elementsPerPage: this.allEpeople.length,
|
||||||
totalElements: this.allEpeople.length,
|
totalElements: this.allEpeople.length,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
}), this.allEpeople));
|
}), this.allEpeople));
|
||||||
}
|
}
|
||||||
const result = this.allEpeople.find((ePerson: EPerson) => {
|
const result = this.allEpeople.find((ePerson: EPerson) => {
|
||||||
@@ -85,14 +110,14 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
elementsPerPage: [result].length,
|
elementsPerPage: [result].length,
|
||||||
totalElements: [result].length,
|
totalElements: [result].length,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
}), [result]));
|
}), [result]));
|
||||||
}
|
}
|
||||||
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
|
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
|
||||||
elementsPerPage: this.allEpeople.length,
|
elementsPerPage: this.allEpeople.length,
|
||||||
totalElements: this.allEpeople.length,
|
totalElements: this.allEpeople.length,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
}), this.allEpeople));
|
}), this.allEpeople));
|
||||||
},
|
},
|
||||||
deleteEPerson(ePerson: EPerson): Observable<boolean> {
|
deleteEPerson(ePerson: EPerson): Observable<boolean> {
|
||||||
@@ -112,23 +137,17 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
},
|
},
|
||||||
getEPeoplePageRouterLink(): string {
|
getEPeoplePageRouterLink(): string {
|
||||||
return '/access-control/epeople';
|
return '/access-control/epeople';
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||||
isAuthorized: observableOf(true)
|
isAuthorized: observableOf(true),
|
||||||
});
|
});
|
||||||
builderService = getMockFormBuilderService();
|
builderService = getMockFormBuilderService();
|
||||||
translateService = getMockTranslateService();
|
|
||||||
|
|
||||||
paginationService = new PaginationServiceStub();
|
paginationService = new PaginationServiceStub();
|
||||||
TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot(),
|
||||||
loader: {
|
|
||||||
provide: TranslateLoader,
|
|
||||||
useClass: TranslateLoaderMock
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
declarations: [EPeopleRegistryComponent],
|
declarations: [EPeopleRegistryComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -138,16 +157,16 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
{ provide: FormBuilderService, useValue: builderService },
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
{ provide: Router, useValue: new RouterStub() },
|
{ provide: Router, useValue: new RouterStub() },
|
||||||
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
|
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
|
||||||
{ provide: PaginationService, useValue: paginationService }
|
{ provide: PaginationService, useValue: paginationService },
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(EPeopleRegistryComponent);
|
fixture = TestBed.createComponent(EPeopleRegistryComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
modalService = (component as any).modalService;
|
modalService = TestBed.inject(NgbModal);
|
||||||
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
|
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@@ -157,10 +176,10 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display list of ePeople', () => {
|
it('should display list of ePeople', () => {
|
||||||
const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
|
const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
|
||||||
expect(ePeopleIdsFound.length).toEqual(2);
|
expect(ePeopleIdsFound.length).toEqual(2);
|
||||||
mockEPeople.map((ePerson: EPerson) => {
|
mockEPeople.map((ePerson: EPerson) => {
|
||||||
expect(ePeopleIdsFound.find((foundEl) => {
|
expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
|
||||||
return (foundEl.nativeElement.textContent.trim() === ePerson.uuid);
|
return (foundEl.nativeElement.textContent.trim() === ePerson.uuid);
|
||||||
})).toBeTruthy();
|
})).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -168,7 +187,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
describe('when searching with scope/query (scope metadata)', () => {
|
describe('when searching with scope/query (scope metadata)', () => {
|
||||||
let ePeopleIdsFound;
|
let ePeopleIdsFound: DebugElement[];
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
component.search({ scope: 'metadata', query: EPersonMock2.name });
|
component.search({ scope: 'metadata', query: EPersonMock2.name });
|
||||||
tick();
|
tick();
|
||||||
@@ -178,14 +197,14 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
|
|
||||||
it('should display search result', () => {
|
it('should display search result', () => {
|
||||||
expect(ePeopleIdsFound.length).toEqual(1);
|
expect(ePeopleIdsFound.length).toEqual(1);
|
||||||
expect(ePeopleIdsFound.find((foundEl) => {
|
expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
|
||||||
return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid);
|
return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid);
|
||||||
})).toBeTruthy();
|
})).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when searching with scope/query (scope email)', () => {
|
describe('when searching with scope/query (scope email)', () => {
|
||||||
let ePeopleIdsFound;
|
let ePeopleIdsFound: DebugElement[];
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
component.search({ scope: 'email', query: EPersonMock.email });
|
component.search({ scope: 'email', query: EPersonMock.email });
|
||||||
tick();
|
tick();
|
||||||
@@ -195,43 +214,13 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
|
|
||||||
it('should display search result', () => {
|
it('should display search result', () => {
|
||||||
expect(ePeopleIdsFound.length).toEqual(1);
|
expect(ePeopleIdsFound.length).toEqual(1);
|
||||||
expect(ePeopleIdsFound.find((foundEl) => {
|
expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
|
||||||
return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid);
|
return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid);
|
||||||
})).toBeTruthy();
|
})).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toggleEditEPerson', () => {
|
|
||||||
describe('when you click on first edit eperson button', () => {
|
|
||||||
beforeEach(fakeAsync(() => {
|
|
||||||
const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton'));
|
|
||||||
editButtons[0].triggerEventHandler('click', {
|
|
||||||
preventDefault: () => {/**/
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tick();
|
|
||||||
fixture.detectChanges();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('editEPerson form is toggled', () => {
|
|
||||||
const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
|
|
||||||
ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => {
|
|
||||||
if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) {
|
|
||||||
expect(component.isEPersonFormShown).toEqual(false);
|
|
||||||
} else {
|
|
||||||
expect(component.isEPersonFormShown).toEqual(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('EPerson search section is hidden', () => {
|
|
||||||
expect(fixture.debugElement.query(By.css('#search'))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteEPerson', () => {
|
describe('deleteEPerson', () => {
|
||||||
describe('when you click on first delete eperson button', () => {
|
describe('when you click on first delete eperson button', () => {
|
||||||
let ePeopleIdsFoundBeforeDelete;
|
let ePeopleIdsFoundBeforeDelete;
|
||||||
@@ -241,7 +230,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
|
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
|
||||||
deleteButtons[0].triggerEventHandler('click', {
|
deleteButtons[0].triggerEventHandler('click', {
|
||||||
preventDefault: () => {/**/
|
preventDefault: () => {/**/
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
tick();
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -257,20 +246,12 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete EPerson button when the isAuthorized returns false', () => {
|
|
||||||
let ePeopleDeleteButton;
|
|
||||||
beforeEach(() => {
|
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
|
||||||
isAuthorized: observableOf(false)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be disabled', () => {
|
it('should hide delete EPerson button when the isAuthorized returns false', () => {
|
||||||
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
|
spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false));
|
||||||
ePeopleDeleteButton.forEach((deleteButton) => {
|
component.initialisePage();
|
||||||
expect(deleteButton.nativeElement.disabled).toBe(true);
|
fixture.detectChanges();
|
||||||
});
|
|
||||||
|
|
||||||
});
|
expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,26 +1,51 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import {
|
||||||
import { FormBuilder } from '@angular/forms';
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
import {
|
||||||
import { map, switchMap, take } from 'rxjs/operators';
|
BehaviorSubject,
|
||||||
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
|
combineLatest,
|
||||||
|
Observable,
|
||||||
|
Subscription,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||||
|
import {
|
||||||
|
buildPaginatedList,
|
||||||
|
PaginatedList,
|
||||||
|
} from '../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { RequestService } from '../../core/data/request.service';
|
||||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||||
|
import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model';
|
||||||
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
|
import { NoContent } from '../../core/shared/NoContent.model';
|
||||||
|
import {
|
||||||
|
getAllSucceededRemoteData,
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
} from '../../core/shared/operators';
|
||||||
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
|
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
|
||||||
import { hasValue } from '../../shared/empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model';
|
import {
|
||||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
getEPersonEditRoute,
|
||||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
getEPersonsRoute,
|
||||||
import { getAllSucceededRemoteData, getFirstCompletedRemoteData } from '../../core/shared/operators';
|
} from '../access-control-routing-paths';
|
||||||
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
|
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
import { RequestService } from '../../core/data/request.service';
|
|
||||||
import { PageInfo } from '../../core/shared/page-info.model';
|
|
||||||
import { NoContent } from '../../core/shared/NoContent.model';
|
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-epeople-registry',
|
selector: 'ds-epeople-registry',
|
||||||
@@ -60,14 +85,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
id: 'elp',
|
id: 'elp',
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
currentPage: 1
|
currentPage: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether or not to show the EPerson form
|
|
||||||
*/
|
|
||||||
isEPersonFormShown: boolean;
|
|
||||||
|
|
||||||
// The search form
|
// The search form
|
||||||
searchForm;
|
searchForm;
|
||||||
|
|
||||||
@@ -89,11 +109,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private authorizationService: AuthorizationDataService,
|
private authorizationService: AuthorizationDataService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: UntypedFormBuilder,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
public requestService: RequestService) {
|
public requestService: RequestService,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
this.currentSearchQuery = '';
|
this.currentSearchQuery = '';
|
||||||
this.currentSearchScope = 'metadata';
|
this.currentSearchScope = 'metadata';
|
||||||
this.searchForm = this.formBuilder.group(({
|
this.searchForm = this.formBuilder.group(({
|
||||||
@@ -111,24 +133,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
initialisePage() {
|
initialisePage() {
|
||||||
this.searching$.next(true);
|
this.searching$.next(true);
|
||||||
this.isEPersonFormShown = false;
|
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||||
this.search({scope: this.currentSearchScope, query: this.currentSearchQuery});
|
|
||||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
|
||||||
if (eperson != null && eperson.id) {
|
|
||||||
this.isEPersonFormShown = true;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
this.subs.push(this.ePeople$.pipe(
|
this.subs.push(this.ePeople$.pipe(
|
||||||
switchMap((epeople: PaginatedList<EPerson>) => {
|
switchMap((epeople: PaginatedList<EPerson>) => {
|
||||||
if (epeople.pageInfo.totalElements > 0) {
|
if (epeople.pageInfo.totalElements > 0) {
|
||||||
return combineLatest(...epeople.page.map((eperson) => {
|
return combineLatest(epeople.page.map((eperson: EPerson) => {
|
||||||
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
|
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
|
||||||
map((authorized) => {
|
map((authorized) => {
|
||||||
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
||||||
epersonDtoModel.ableToDelete = authorized;
|
epersonDtoModel.ableToDelete = authorized;
|
||||||
epersonDtoModel.eperson = eperson;
|
epersonDtoModel.eperson = eperson;
|
||||||
return epersonDtoModel;
|
return epersonDtoModel;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
})).pipe(map((dtos: EpersonDtoModel[]) => {
|
})).pipe(map((dtos: EpersonDtoModel[]) => {
|
||||||
return buildPaginatedList(epeople.pageInfo, dtos);
|
return buildPaginatedList(epeople.pageInfo, dtos);
|
||||||
@@ -154,34 +170,34 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.findListOptionsSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
|
this.findListOptionsSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
|
||||||
switchMap((findListOptions) => {
|
switchMap((findListOptions) => {
|
||||||
const query: string = data.query;
|
const query: string = data.query;
|
||||||
const scope: string = data.scope;
|
const scope: string = data.scope;
|
||||||
if (query != null && this.currentSearchQuery !== query) {
|
if (query != null && this.currentSearchQuery !== query) {
|
||||||
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], {
|
void this.router.navigate([getEPersonsRoute()], {
|
||||||
queryParamsHandling: 'merge'
|
queryParamsHandling: 'merge',
|
||||||
});
|
|
||||||
this.currentSearchQuery = query;
|
|
||||||
this.paginationService.resetPage(this.config.id);
|
|
||||||
}
|
|
||||||
if (scope != null && this.currentSearchScope !== scope) {
|
|
||||||
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], {
|
|
||||||
queryParamsHandling: 'merge'
|
|
||||||
});
|
|
||||||
this.currentSearchScope = scope;
|
|
||||||
this.paginationService.resetPage(this.config.id);
|
|
||||||
|
|
||||||
}
|
|
||||||
return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
|
||||||
currentPage: findListOptions.currentPage,
|
|
||||||
elementsPerPage: findListOptions.pageSize
|
|
||||||
});
|
});
|
||||||
|
this.currentSearchQuery = query;
|
||||||
|
this.paginationService.resetPage(this.config.id);
|
||||||
}
|
}
|
||||||
|
if (scope != null && this.currentSearchScope !== scope) {
|
||||||
|
void this.router.navigate([getEPersonsRoute()], {
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
});
|
||||||
|
this.currentSearchScope = scope;
|
||||||
|
this.paginationService.resetPage(this.config.id);
|
||||||
|
|
||||||
|
}
|
||||||
|
return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||||
|
currentPage: findListOptions.currentPage,
|
||||||
|
elementsPerPage: findListOptions.pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
getAllSucceededRemoteData(),
|
getAllSucceededRemoteData(),
|
||||||
).subscribe((peopleRD) => {
|
).subscribe((peopleRD) => {
|
||||||
this.ePeople$.next(peopleRD.payload);
|
this.ePeople$.next(peopleRD.payload);
|
||||||
this.pageInfoState$.next(peopleRD.payload.pageInfo);
|
this.pageInfoState$.next(peopleRD.payload.pageInfo);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +207,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
isActive(eperson: EPerson): Observable<boolean> {
|
isActive(eperson: EPerson): Observable<boolean> {
|
||||||
return this.getActiveEPerson().pipe(
|
return this.getActiveEPerson().pipe(
|
||||||
map((activeEPerson) => eperson === activeEPerson)
|
map((activeEPerson) => eperson === activeEPerson),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,30 +218,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
return this.epersonService.getActiveEPerson();
|
return this.epersonService.getActiveEPerson();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start editing the selected EPerson
|
|
||||||
* @param ePerson
|
|
||||||
*/
|
|
||||||
toggleEditEPerson(ePerson: EPerson) {
|
|
||||||
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
|
||||||
if (ePerson === activeEPerson) {
|
|
||||||
this.epersonService.cancelEditEPerson();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
} else {
|
|
||||||
this.epersonService.editEPerson(ePerson);
|
|
||||||
this.isEPersonFormShown = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.scrollToTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
||||||
*/
|
*/
|
||||||
deleteEPerson(ePerson: EPerson) {
|
deleteEPerson(ePerson: EPerson) {
|
||||||
if (hasValue(ePerson.id)) {
|
if (hasValue(ePerson.id)) {
|
||||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||||
modalRef.componentInstance.dso = ePerson;
|
modalRef.componentInstance.name = this.dsoNameService.getName(ePerson);
|
||||||
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
||||||
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
||||||
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
||||||
@@ -237,9 +236,9 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
if (hasValue(ePerson.id)) {
|
if (hasValue(ePerson.id)) {
|
||||||
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||||
if (restResponse.hasSucceeded) {
|
if (restResponse.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name}));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(ePerson) }));
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -261,16 +260,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop() {
|
|
||||||
(function smoothscroll() {
|
|
||||||
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
|
||||||
if (currentScroll > 0) {
|
|
||||||
window.requestAnimationFrame(smoothscroll);
|
|
||||||
window.scrollTo(0, currentScroll - (currentScroll / 8));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset all input-fields to be empty and search all search
|
* Reset all input-fields to be empty and search all search
|
||||||
*/
|
*/
|
||||||
@@ -278,20 +267,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
this.searchForm.patchValue({
|
this.searchForm.patchValue({
|
||||||
query: '',
|
query: '',
|
||||||
});
|
});
|
||||||
this.search({query: ''});
|
this.search({ query: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getEditEPeoplePage(id: string): string {
|
||||||
* This method will set everything to stale, which will cause the lists on this page to update.
|
return getEPersonEditRoute(id);
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.epersonService.getBrowseEndpoint().pipe(
|
|
||||||
take(1)
|
|
||||||
).subscribe((href: string) => {
|
|
||||||
this.requestService.setStaleByHrefSubstring(href).pipe(take(1)).subscribe(() => {
|
|
||||||
this.epersonService.cancelEditEPerson();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,12 @@
|
|||||||
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions';
|
|
||||||
import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers';
|
|
||||||
import { EPersonMock } from '../../shared/testing/eperson.mock';
|
import { EPersonMock } from '../../shared/testing/eperson.mock';
|
||||||
|
import {
|
||||||
|
EPeopleRegistryCancelEPersonAction,
|
||||||
|
EPeopleRegistryEditEPersonAction,
|
||||||
|
} from './epeople-registry.actions';
|
||||||
|
import {
|
||||||
|
ePeopleRegistryReducer,
|
||||||
|
EPeopleRegistryState,
|
||||||
|
} from './epeople-registry.reducers';
|
||||||
|
|
||||||
const initialState: EPeopleRegistryState = {
|
const initialState: EPeopleRegistryState = {
|
||||||
editEPerson: null,
|
editEPerson: null,
|
||||||
|
@@ -2,7 +2,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model';
|
|||||||
import {
|
import {
|
||||||
EPeopleRegistryAction,
|
EPeopleRegistryAction,
|
||||||
EPeopleRegistryActionTypes,
|
EPeopleRegistryActionTypes,
|
||||||
EPeopleRegistryEditEPersonAction
|
EPeopleRegistryEditEPersonAction,
|
||||||
} from './epeople-registry.actions';
|
} from './epeople-registry.actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,13 +30,13 @@ export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegi
|
|||||||
|
|
||||||
case EPeopleRegistryActionTypes.EDIT_EPERSON: {
|
case EPeopleRegistryActionTypes.EDIT_EPERSON: {
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson
|
editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: {
|
case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: {
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
editEPerson: null
|
editEPerson: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,84 +1,97 @@
|
|||||||
<div *ngIf="epersonService.getActiveEPerson() | async; then editheader; else createHeader"></div>
|
<div class="container">
|
||||||
|
<div class="group-form row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
<ng-template #createHeader>
|
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
|
||||||
<h4>{{messagePrefix + '.create' | translate}}</h4>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template #editheader>
|
<ng-template #createHeader>
|
||||||
<h4>{{messagePrefix + '.edit' | translate}}</h4>
|
<h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ds-form [formId]="formId"
|
<ng-template #editHeader>
|
||||||
[formModel]="formModel"
|
<h1 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h1>
|
||||||
[formGroup]="formGroup"
|
</ng-template>
|
||||||
[formLayout]="formLayout"
|
|
||||||
[displayCancel]="false"
|
|
||||||
(submitForm)="onSubmit()">
|
|
||||||
<div before class="btn-group">
|
|
||||||
<button (click)="onCancel()"
|
|
||||||
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
|
|
||||||
</div>
|
|
||||||
<div between class="btn-group">
|
|
||||||
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
|
|
||||||
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div between class="btn-group ml-1">
|
|
||||||
<button *ngIf="!isImpersonated" class="btn btn-primary" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
|
|
||||||
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
|
|
||||||
</button>
|
|
||||||
<button *ngIf="isImpersonated" class="btn btn-primary" (click)="stopImpersonating()">
|
|
||||||
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button after class="btn btn-danger delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
|
|
||||||
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
|
|
||||||
</button>
|
|
||||||
</ds-form>
|
|
||||||
|
|
||||||
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
|
<ds-form [formId]="formId"
|
||||||
|
[formModel]="formModel"
|
||||||
|
[formGroup]="formGroup"
|
||||||
|
[formLayout]="formLayout"
|
||||||
|
[displayCancel]="false"
|
||||||
|
[submitLabel]="submitLabel"
|
||||||
|
(submitForm)="onSubmit()">
|
||||||
|
<div before class="btn-group">
|
||||||
|
<button (click)="onCancel()" type="button" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="displayResetPassword" between class="btn-group">
|
||||||
|
<button class="btn btn-primary" [disabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
|
||||||
|
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="canImpersonate$ | async" between class="btn-group">
|
||||||
|
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" (click)="impersonate()">
|
||||||
|
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
|
||||||
|
</button>
|
||||||
|
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
|
||||||
|
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button *ngIf="canDelete$ | async" after class="btn btn-danger delete-button" type="button" (click)="delete()">
|
||||||
|
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
|
||||||
|
</button>
|
||||||
|
</ds-form>
|
||||||
|
|
||||||
<div *ngIf="epersonService.getActiveEPerson() | async">
|
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
|
||||||
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
|
|
||||||
|
|
||||||
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
|
<div *ngIf="epersonService.getActiveEPerson() | async">
|
||||||
|
<h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
|
||||||
|
|
||||||
<ds-pagination
|
<ds-themed-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-themed-loading>
|
||||||
*ngIf="(groups | async)?.payload?.totalElements > 0"
|
|
||||||
[paginationOptions]="config"
|
|
||||||
[pageInfoState]="(groups | async)?.payload"
|
|
||||||
[collectionSize]="(groups | async)?.payload?.totalElements"
|
|
||||||
[hideGear]="true"
|
|
||||||
[hidePagerWhenSinglePage]="true"
|
|
||||||
(pageChange)="onPageChange($event)">
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
<ds-pagination
|
||||||
<table id="groups" class="table table-striped table-hover table-bordered">
|
*ngIf="(groups$ | async)?.payload?.totalElements > 0"
|
||||||
<thead>
|
[paginationOptions]="config"
|
||||||
<tr>
|
[pageInfoState]="groupsPageInfoState$"
|
||||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
[collectionSize]="(groups$ | async)?.payload?.totalElements"
|
||||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
[hideGear]="true"
|
||||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
[hidePagerWhenSinglePage]="true"
|
||||||
</tr>
|
(pageChange)="onPageChange($event)">
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
|
||||||
<td class="align-middle">{{group.id}}</td>
|
|
||||||
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
|
|
||||||
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
|
||||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ds-pagination>
|
<div class="table-responsive">
|
||||||
|
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let group of (groups$ | async)?.payload?.page">
|
||||||
|
<td class="align-middle">{{group.id}}</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||||
|
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
|
||||||
|
{{ dsoNameService.getName(group) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
</ds-pagination>
|
||||||
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
|
|
||||||
<div>
|
<div *ngIf="(groups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||||
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
|
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
|
||||||
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
|
<div>
|
||||||
|
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
|
||||||
|
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,36 +1,70 @@
|
|||||||
import { Observable, of as observableOf } from 'rxjs';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
import {
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
ComponentFixture,
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
UntypedFormControl,
|
||||||
|
UntypedFormGroup,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import {
|
||||||
|
BrowserModule,
|
||||||
|
By,
|
||||||
|
} from '@angular/platform-browser';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
Router,
|
||||||
|
} from '@angular/router';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
import {
|
||||||
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
|
TranslateLoader,
|
||||||
|
TranslateModule,
|
||||||
|
} from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
Observable,
|
||||||
|
of as observableOf,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from '../../../core/auth/auth.service';
|
||||||
|
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
|
||||||
|
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { FindListOptions } from '../../../core/data/find-list-options.model';
|
||||||
|
import {
|
||||||
|
buildPaginatedList,
|
||||||
|
PaginatedList,
|
||||||
|
} from '../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||||
|
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
|
||||||
|
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
|
||||||
|
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
|
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
|
||||||
|
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
|
||||||
|
import {
|
||||||
|
EPersonMock,
|
||||||
|
EPersonMock2,
|
||||||
|
} from '../../../shared/testing/eperson.mock';
|
||||||
|
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
||||||
|
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router.stub';
|
||||||
|
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||||
|
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
import { HasNoValuePipe } from '../../../shared/utils/has-no-value.pipe';
|
||||||
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
||||||
import { EPersonFormComponent } from './eperson-form.component';
|
import { EPersonFormComponent } from './eperson-form.component';
|
||||||
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock';
|
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
|
||||||
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
|
|
||||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
|
||||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
|
||||||
import { AuthService } from '../../../core/auth/auth.service';
|
|
||||||
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
|
|
||||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
|
||||||
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
|
||||||
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
|
||||||
import { RequestService } from '../../../core/data/request.service';
|
|
||||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
|
||||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
|
||||||
import { FindListOptions } from '../../../core/data/find-list-options.model';
|
|
||||||
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||||
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
|
|
||||||
|
|
||||||
describe('EPersonFormComponent', () => {
|
describe('EPersonFormComponent', () => {
|
||||||
let component: EPersonFormComponent;
|
let component: EPersonFormComponent;
|
||||||
@@ -43,6 +77,8 @@ describe('EPersonFormComponent', () => {
|
|||||||
let authorizationService: AuthorizationDataService;
|
let authorizationService: AuthorizationDataService;
|
||||||
let groupsDataService: GroupDataService;
|
let groupsDataService: GroupDataService;
|
||||||
let epersonRegistrationService: EpersonRegistrationService;
|
let epersonRegistrationService: EpersonRegistrationService;
|
||||||
|
let route: ActivatedRouteStub;
|
||||||
|
let router: RouterStub;
|
||||||
|
|
||||||
let paginationService;
|
let paginationService;
|
||||||
|
|
||||||
@@ -106,70 +142,73 @@ describe('EPersonFormComponent', () => {
|
|||||||
},
|
},
|
||||||
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
|
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
|
||||||
return createSuccessfulRemoteDataObject$(null);
|
return createSuccessfulRemoteDataObject$(null);
|
||||||
}
|
},
|
||||||
|
findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> {
|
||||||
|
return createSuccessfulRemoteDataObject$(null);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
builderService = Object.assign(getMockFormBuilderService(),{
|
builderService = Object.assign(getMockFormBuilderService(),{
|
||||||
createFormGroup(formModel, options = null) {
|
createFormGroup(formModel, options = null) {
|
||||||
const controls = {};
|
const controls = {};
|
||||||
formModel.forEach( model => {
|
formModel.forEach( model => {
|
||||||
model.parent = parent;
|
model.parent = parent;
|
||||||
const controlModel = model;
|
const controlModel = model;
|
||||||
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
||||||
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
||||||
controls[model.id] = new FormControl(controlState, controlOptions);
|
controls[model.id] = new UntypedFormControl(controlState, controlOptions);
|
||||||
});
|
});
|
||||||
return new FormGroup(controls, options);
|
return new UntypedFormGroup(controls, options);
|
||||||
},
|
},
|
||||||
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
||||||
return {
|
return {
|
||||||
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
|
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getValidators(validatorsConfig) {
|
getValidators(validatorsConfig) {
|
||||||
return this.getValidatorFns(validatorsConfig);
|
return this.getValidatorFns(validatorsConfig);
|
||||||
},
|
},
|
||||||
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
|
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
|
||||||
let validatorFns = [];
|
let validatorFns = [];
|
||||||
if (this.isObject(validatorsConfig)) {
|
if (this.isObject(validatorsConfig)) {
|
||||||
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
|
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
|
||||||
const validatorConfigValue = validatorsConfig[validatorConfigKey];
|
const validatorConfigValue = validatorsConfig[validatorConfigKey];
|
||||||
if (this.isValidatorDescriptor(validatorConfigValue)) {
|
if (this.isValidatorDescriptor(validatorConfigValue)) {
|
||||||
const descriptor = validatorConfigValue;
|
const descriptor = validatorConfigValue;
|
||||||
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
|
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
|
||||||
}
|
}
|
||||||
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
|
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return validatorFns;
|
return validatorFns;
|
||||||
},
|
},
|
||||||
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
|
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
|
||||||
let validatorFn;
|
let validatorFn;
|
||||||
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
|
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
|
||||||
validatorFn = Validators[validatorName];
|
validatorFn = Validators[validatorName];
|
||||||
} else { // Custom Validators
|
} else { // Custom Validators
|
||||||
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
|
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
|
||||||
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
|
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
|
||||||
} else if (validatorsToken) {
|
} else if (validatorsToken) {
|
||||||
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
|
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (validatorFn === undefined) { // throw when no validator could be resolved
|
if (validatorFn === undefined) { // throw when no validator could be resolved
|
||||||
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
|
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
|
||||||
}
|
}
|
||||||
if (validatorArgs !== null) {
|
if (validatorArgs !== null) {
|
||||||
return validatorFn(validatorArgs);
|
return validatorFn(validatorArgs);
|
||||||
}
|
}
|
||||||
return validatorFn;
|
return validatorFn;
|
||||||
},
|
},
|
||||||
isValidatorDescriptor(value) {
|
isValidatorDescriptor(value) {
|
||||||
if (this.isObject(value)) {
|
if (this.isObject(value)) {
|
||||||
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
|
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
isObject(value) {
|
isObject(value) {
|
||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
authService = new AuthServiceStub();
|
authService = new AuthServiceStub();
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||||
@@ -178,20 +217,25 @@ describe('EPersonFormComponent', () => {
|
|||||||
});
|
});
|
||||||
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
||||||
findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
||||||
getGroupRegistryRouterLink: ''
|
getGroupRegistryRouterLink: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
paginationService = new PaginationServiceStub();
|
paginationService = new PaginationServiceStub();
|
||||||
|
route = new ActivatedRouteStub();
|
||||||
|
router = new RouterStub();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
loader: {
|
loader: {
|
||||||
provide: TranslateLoader,
|
provide: TranslateLoader,
|
||||||
useClass: TranslateLoaderMock
|
useClass: TranslateLoaderMock,
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
declarations: [EPersonFormComponent],
|
declarations: [
|
||||||
|
EPersonFormComponent,
|
||||||
|
HasNoValuePipe,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
{ provide: GroupDataService, useValue: groupsDataService },
|
{ provide: GroupDataService, useValue: groupsDataService },
|
||||||
@@ -200,16 +244,18 @@ describe('EPersonFormComponent', () => {
|
|||||||
{ provide: AuthService, useValue: authService },
|
{ provide: AuthService, useValue: authService },
|
||||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||||
{ provide: PaginationService, useValue: paginationService },
|
{ provide: PaginationService, useValue: paginationService },
|
||||||
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])},
|
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
|
||||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||||
EPeopleRegistryComponent
|
{ provide: ActivatedRoute, useValue: route },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
EPeopleRegistryComponent,
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||||
registerEmail: createSuccessfulRemoteDataObject$(null)
|
registerEmail: createSuccessfulRemoteDataObject$(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -241,12 +287,12 @@ describe('EPersonFormComponent', () => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
'eperson.firstname': [
|
'eperson.firstname': [
|
||||||
{
|
{
|
||||||
value: firstName
|
value: firstName,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
'eperson.lastname': [
|
'eperson.lastname': [
|
||||||
{
|
{
|
||||||
value: lastName
|
value: lastName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -263,24 +309,18 @@ describe('EPersonFormComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
describe('firstName, lastName and email should be required', () => {
|
describe('firstName, lastName and email should be required', () => {
|
||||||
it('form should be invalid because the firstName is required', waitForAsync(() => {
|
it('form should be invalid because the firstName is required', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.formGroup.controls.firstName.valid).toBeFalse();
|
||||||
expect(component.formGroup.controls.firstName.valid).toBeFalse();
|
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
|
||||||
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
|
});
|
||||||
});
|
it('form should be invalid because the lastName is required', () => {
|
||||||
}));
|
expect(component.formGroup.controls.lastName.valid).toBeFalse();
|
||||||
it('form should be invalid because the lastName is required', waitForAsync(() => {
|
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
|
||||||
fixture.whenStable().then(() => {
|
});
|
||||||
expect(component.formGroup.controls.lastName.valid).toBeFalse();
|
it('form should be invalid because the email is required', () => {
|
||||||
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
|
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||||
});
|
expect(component.formGroup.controls.email.errors.required).toBeTrue();
|
||||||
}));
|
});
|
||||||
it('form should be invalid because the email is required', waitForAsync(() => {
|
|
||||||
fixture.whenStable().then(() => {
|
|
||||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
|
||||||
expect(component.formGroup.controls.email.errors.required).toBeTrue();
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('after inserting information firstName,lastName and email not required', () => {
|
describe('after inserting information firstName,lastName and email not required', () => {
|
||||||
@@ -290,24 +330,18 @@ describe('EPersonFormComponent', () => {
|
|||||||
component.formGroup.controls.email.setValue('test@test.com');
|
component.formGroup.controls.email.setValue('test@test.com');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('firstName should be valid because the firstName is set', waitForAsync(() => {
|
it('firstName should be valid because the firstName is set', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.formGroup.controls.firstName.valid).toBeTrue();
|
||||||
expect(component.formGroup.controls.firstName.valid).toBeTrue();
|
expect(component.formGroup.controls.firstName.errors).toBeNull();
|
||||||
expect(component.formGroup.controls.firstName.errors).toBeNull();
|
});
|
||||||
});
|
it('lastName should be valid because the lastName is set', () => {
|
||||||
}));
|
expect(component.formGroup.controls.lastName.valid).toBeTrue();
|
||||||
it('lastName should be valid because the lastName is set', waitForAsync(() => {
|
expect(component.formGroup.controls.lastName.errors).toBeNull();
|
||||||
fixture.whenStable().then(() => {
|
});
|
||||||
expect(component.formGroup.controls.lastName.valid).toBeTrue();
|
it('email should be valid because the email is set', () => {
|
||||||
expect(component.formGroup.controls.lastName.errors).toBeNull();
|
expect(component.formGroup.controls.email.valid).toBeTrue();
|
||||||
});
|
expect(component.formGroup.controls.email.errors).toBeNull();
|
||||||
}));
|
});
|
||||||
it('email should be valid because the email is set', waitForAsync(() => {
|
|
||||||
fixture.whenStable().then(() => {
|
|
||||||
expect(component.formGroup.controls.email.valid).toBeTrue();
|
|
||||||
expect(component.formGroup.controls.email.errors).toBeNull();
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -316,12 +350,10 @@ describe('EPersonFormComponent', () => {
|
|||||||
component.formGroup.controls.email.setValue('test@test');
|
component.formGroup.controls.email.setValue('test@test');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('email should not be valid because the email pattern', waitForAsync(() => {
|
it('email should not be valid because the email pattern', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
|
||||||
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
|
});
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('after already utilized email', () => {
|
describe('after already utilized email', () => {
|
||||||
@@ -329,19 +361,17 @@ describe('EPersonFormComponent', () => {
|
|||||||
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
|
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
|
||||||
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
|
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
|
||||||
return createSuccessfulRemoteDataObject$(EPersonMock);
|
return createSuccessfulRemoteDataObject$(EPersonMock);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
component.formGroup.controls.email.setValue('test@test.com');
|
component.formGroup.controls.email.setValue('test@test.com');
|
||||||
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
|
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('email should not be valid because email is already taken', waitForAsync(() => {
|
it('email should not be valid because email is already taken', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
|
||||||
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
|
});
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -366,12 +396,12 @@ describe('EPersonFormComponent', () => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
'eperson.firstname': [
|
'eperson.firstname': [
|
||||||
{
|
{
|
||||||
value: firstName
|
value: firstName,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
'eperson.lastname': [
|
'eperson.lastname': [
|
||||||
{
|
{
|
||||||
value: lastName
|
value: lastName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -393,11 +423,9 @@ describe('EPersonFormComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit a new eperson using the correct values', waitForAsync(() => {
|
it('should emit a new eperson using the correct values', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
});
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with an active eperson', () => {
|
describe('with an active eperson', () => {
|
||||||
@@ -409,30 +437,28 @@ describe('EPersonFormComponent', () => {
|
|||||||
metadata: {
|
metadata: {
|
||||||
'eperson.firstname': [
|
'eperson.firstname': [
|
||||||
{
|
{
|
||||||
value: firstName
|
value: firstName,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
'eperson.lastname': [
|
'eperson.lastname': [
|
||||||
{
|
{
|
||||||
value: lastName
|
value: lastName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
email: email,
|
email: email,
|
||||||
canLogIn: canLogIn,
|
canLogIn: canLogIn,
|
||||||
requireCertificate: requireCertificate,
|
requireCertificate: requireCertificate,
|
||||||
_links: undefined
|
_links: undefined,
|
||||||
});
|
});
|
||||||
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
|
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit the existing eperson using the correct values', waitForAsync(() => {
|
it('should emit the existing eperson using the correct values', () => {
|
||||||
fixture.whenStable().then(() => {
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
|
||||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
|
});
|
||||||
});
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,7 +469,7 @@ describe('EPersonFormComponent', () => {
|
|||||||
spyOn(authService, 'impersonate').and.callThrough();
|
spyOn(authService, 'impersonate').and.callThrough();
|
||||||
ePersonId = 'testEPersonId';
|
ePersonId = 'testEPersonId';
|
||||||
component.epersonInitial = Object.assign(new EPerson(), {
|
component.epersonInitial = Object.assign(new EPerson(), {
|
||||||
id: ePersonId
|
id: ePersonId,
|
||||||
});
|
});
|
||||||
component.impersonate();
|
component.impersonate();
|
||||||
});
|
});
|
||||||
@@ -491,16 +517,16 @@ describe('EPersonFormComponent', () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the delete button should be active if the eperson can be deleted', () => {
|
it('the delete button should be visible if the ePerson can be deleted', () => {
|
||||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||||
expect(deleteButton.nativeElement.disabled).toBe(false);
|
expect(deleteButton).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the delete button should be disabled if the eperson cannot be deleted', () => {
|
it('the delete button should be hidden if the ePerson cannot be deleted', () => {
|
||||||
component.canDelete$ = observableOf(false);
|
component.canDelete$ = observableOf(false);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||||
expect(deleteButton.nativeElement.disabled).toBe(true);
|
expect(deleteButton).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call the epersonFormComponent delete when clicked on the button', () => {
|
it('should call the epersonFormComponent delete when clicked on the button', () => {
|
||||||
@@ -531,13 +557,13 @@ describe('EPersonFormComponent', () => {
|
|||||||
ePersonEmail = 'person.email@4science.it';
|
ePersonEmail = 'person.email@4science.it';
|
||||||
component.epersonInitial = Object.assign(new EPerson(), {
|
component.epersonInitial = Object.assign(new EPerson(), {
|
||||||
id: ePersonId,
|
id: ePersonId,
|
||||||
email: ePersonEmail
|
email: ePersonEmail,
|
||||||
});
|
});
|
||||||
component.resetPassword();
|
component.resetPassword();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call epersonRegistrationService.registerEmail', () => {
|
it('should call epersonRegistrationService.registerEmail', () => {
|
||||||
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail);
|
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail, null, 'forgot');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user