Merge branch 'main-upstream' into w2p-91400_Fix-for-missing-comma-in-json-files

This commit is contained in:
Alexandre Vryghem
2022-05-27 09:48:05 +02:00
1448 changed files with 66802 additions and 32677 deletions

View File

@@ -2,10 +2,16 @@
# 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
> 0.5%
last 2 versions
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 9-11 # For IE 9-11 support, remove 'not'.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

222
.eslintrc.json Normal file
View File

@@ -0,0 +1,222 @@
{
"root": true,
"plugins": [
"@typescript-eslint",
"@angular-eslint/eslint-plugin",
"eslint-plugin-import",
"eslint-plugin-jsdoc",
"eslint-plugin-deprecation",
"eslint-plugin-unused-imports"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./cypress/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"max-classes-per-file": [
"error",
1
],
"comma-dangle": [
"off",
"always-multiline"
],
"eol-last": [
"error",
"always"
],
"no-console": [
"error",
{
"allow": [
"log",
"warn",
"dir",
"timeLog",
"assert",
"clear",
"count",
"countReset",
"group",
"groupEnd",
"table",
"debug",
"info",
"dirxml",
"error",
"groupCollapsed",
"Console",
"profile",
"profileEnd",
"timeStamp",
"context"
]
}
],
"curly": "error",
"brace-style": [
"error",
"1tbs",
{
"allowSingleLine": true
}
],
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"radix": "error",
"guard-for-in": "error",
"no-bitwise": "error",
"no-restricted-imports": "error",
"no-caller": "error",
"no-debugger": "error",
"no-redeclare": "error",
"no-eval": "error",
"no-fallthrough": "error",
"no-trailing-spaces": "error",
"space-infix-ops": "error",
"keyword-spacing": "error",
"no-var": "error",
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint)
"prefer-spread": "off",
"no-underscore-dangle": "off",
// todo: disabled rules from eslint:recommended, consider re-enabling & fixing
"no-prototype-builtins": "off",
"no-useless-escape": "off",
"no-case-declarations": "off",
"no-extra-boolean-cast": "off",
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "ds",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "ds",
"style": "kebab-case"
}
],
"@angular-eslint/pipe-prefix": [
"error",
{
"prefixes": [
"ds"
]
}
],
"@angular-eslint/no-attribute-decorator": "error",
"@angular-eslint/no-forward-ref": "error",
"@angular-eslint/no-output-native": "warn",
"@angular-eslint/no-output-on-prefix": "warn",
"@angular-eslint/no-conflicting-lifecycle": "warn",
"@typescript-eslint/no-inferrable-types":[
"error",
{
"ignoreParameters": true
}
],
"@typescript-eslint/quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"@typescript-eslint/semi": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "property",
"format": null
}
],
"@typescript-eslint/member-ordering": [
"error",
{
"default": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/restrict-plus-operands": "warn",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/require-await": "off",
"deprecation/deprecation": "warn",
"import/order": "off",
"import/no-deprecated": "warn"
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {
// todo: re-enable & fix errors
"@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off"
}
}
]
}

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -16,9 +16,9 @@ jobs:
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false
# When Chrome version is specified, we pin to a specific version of Chrome & ChromeDriver
# Comment this out to use the latest release of both.
CHROME_VERSION: "90.0.4430.212-1"
# When Chrome version is specified, we pin to a specific version of Chrome
# Comment this out to use the latest release
#CHROME_VERSION: "90.0.4430.212-1"
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
@@ -29,11 +29,11 @@ jobs:
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
uses: actions/checkout@v1
uses: actions/checkout@v2
# https://github.com/actions/setup-node
- name: Install Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
@@ -66,17 +66,14 @@ jobs:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install latest ChromeDriver compatible with installed Chrome
# needs to be npm, the --detect_chromedriver_version flag doesn't work with yarn global
run: |
npm install -g chromedriver --detect_chromedriver_version
chromedriver -v
- name: Install Yarn dependencies
run: yarn install --frozen-lockfile
- name: Run lint
run: yarn run lint
run: yarn run lint --quiet
- name: Check for circular dependencies
run: yarn run check-circ-deps
- name: Run build
run: yarn run build:prod
@@ -88,7 +85,7 @@ jobs:
# Upload coverage reports to Codecov (for Node v12 only)
# https://github.com/codecov/codecov-action
- name: Upload coverage to Codecov.io
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v2
if: matrix.node-version == '12.x'
# Using docker-compose start backend using CI configuration
@@ -99,23 +96,48 @@ jobs:
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
docker container ls
# Wait until the REST API returns a 200 response (or for a max of 30 seconds)
# https://github.com/nev7n/wait_for_response
- name: Wait for DSpace REST Backend to be ready (for e2e tests)
uses: nev7n/wait_for_response@v1
with:
# We use the 'sites' endpoint to also ensure the database is ready
url: 'http://localhost:8080/server/api/core/sites'
responseCode: 200
timeout: 30000
- name: Get DSpace REST Backend info/properties
run: curl http://localhost:8080/server/api
# Run integration tests via Cypress.io
# https://github.com/cypress-io/github-action
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
- name: Run e2e tests (integration tests)
uses: cypress-io/github-action@v2
with:
# Run tests in Chrome, headless mode
browser: chrome
headless: true
# Start app before running tests (will be stopped automatically after tests finish)
start: yarn run serve:ssr
# Wait for backend & frontend to be available
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000
# Wait for 2 mins max for everything to respond
wait-on-timeout: 120
# Cypress always creates a video of all e2e tests (whether they succeeded or failed)
# Save those in an Artifact
- name: Upload e2e test videos to Artifacts
uses: actions/upload-artifact@v2
if: always()
with:
name: e2e-test-videos
path: cypress/videos
# If e2e tests fail, Cypress creates a screenshot of what happened
# Save those in an Artifact
- name: Upload e2e test failure screenshots to Artifacts
uses: actions/upload-artifact@v2
if: failure()
with:
name: e2e-test-screenshots
path: cypress/screenshots
- name: Stop app (in case it stays up after e2e tests)
run: |
chromedriver --url-base='/wd/hub' --port=4444 &
yarn run e2e:ci
app_pid=$(lsof -t -i:4000)
if [[ ! -z $app_pid ]]; then
echo "App was still up! (PID: $app_pid)"
kill -9 $app_pid
fi
# Start up the app with SSR enabled (run in background)
- name: Start app in SSR (server-side rendering) mode

83
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
# DSpace Docker image build for hub.docker.com
name: Docker images
# 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
on:
push:
branches:
- main
- 'dspace-**'
tags:
- 'dspace-**'
pull_request:
jobs:
docker:
# 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
env:
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
# For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image.
# For a new commit on other branches, use the branch name as the tag for Docker image.
# For a new tag, copy that tag name as the tag for Docker image.
IMAGE_TAGS: |
type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }}
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
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
uses: actions/checkout@v2
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v1
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# https://github.com/docker/login-action
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
###############################################
# 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: linux/amd64,linux/arm64
# 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 }}

7
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/.angular/cache
/__build__
/__server_build__
/node_modules
@@ -7,10 +8,6 @@ npm-debug.log
/build/
/src/environments/environment.ts
/src/environments/environment.dev.ts
/src/environments/environment.prod.ts
/coverage
/dist/
@@ -40,3 +37,5 @@ package-lock.json
.env
/nbproject/
junit.xml

View File

@@ -3,5 +3,6 @@
"i18n-ally.localesPaths": [
"src/assets/i18n",
"src/app/core/locale"
]
],
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

@@ -1,7 +1,7 @@
# This image will be published as dspace/dspace-angular
# See https://dspace-labs.github.io/DSpace-Docker-Images/ for usage details
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
FROM node:12-alpine
FROM node:14-alpine
WORKDIR /app
ADD . /app/
EXPOSE 4000
@@ -9,4 +9,9 @@ EXPOSE 4000
# We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
# See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000
CMD yarn run start:dev
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
# Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode
# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485
CMD yarn serve --host 0.0.0.0

18
LICENSE
View File

@@ -1,4 +1,4 @@
DSpace source code BSD License:
BSD 3-Clause License
Copyright (c) 2002-2021, LYRASIS. All rights reserved.
@@ -13,13 +13,12 @@ notice, this list of conditions and the following disclaimer.
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name DuraSpace nor the name of the DSpace Foundation
nor the names of its contributors may be used to endorse or promote
products derived from this software without specific prior written
permission.
- Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
@@ -30,10 +29,3 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.
DSpace uses third-party libraries which may be distributed under
different licenses to the above. Information about these licenses
is detailed in the LICENSES_THIRD_PARTY file at the root of the source
tree. You must agree to the terms of these licenses, in addition to
the above DSpace source code license, in order to use this software.

28
NOTICE Normal file
View File

@@ -0,0 +1,28 @@
Licenses of Third-Party Libraries
=================================
DSpace uses third-party libraries which may be distributed under
different licenses than specified in our LICENSE file. Information
about these licenses is detailed in the LICENSES_THIRD_PARTY file at
the root of the source tree. You must agree to the terms of these
licenses, in addition to the DSpace source code license, in order to
use this software.
Licensing Notices
=================
[July 2019] DuraSpace joined with LYRASIS (another 501(c)3 organization) in July 2019.
LYRASIS holds the copyrights of DuraSpace.
[July 2009] Fedora Commons joined with the DSpace Foundation and began operating under
the new name DuraSpace in July 2009. DuraSpace holds the copyrights of
the DSpace Foundation, Inc.
[July 2007] The DSpace Foundation, Inc. is a 501(c)3 corporation established in July 2007
with a mission to promote and advance the dspace platform enabling management,
access and preservation of digital works. The Foundation was able to transfer
the legal copyright from Hewlett-Packard Company (HP) and Massachusetts
Institute of Technology (MIT) to the DSpace Foundation in October 2007. Many
of the files in the source code may contain a copyright statement stating HP
and MIT possess the copyright, in these instances please note that the copy
right has transferred to the DSpace foundation, and subsequently to DuraSpace.

345
README.md
View File

@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start
-----------
**Ensure you're running [Node](https://nodejs.org) `v12.x` or `v14.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`**
**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`**
```bash
# clone the repo
@@ -69,6 +69,9 @@ Table of Contents
- [Cleaning](#cleaning)
- [Testing](#testing)
- [Test a Pull Request](#test-a-pull-request)
- [Unit Tests](#unit-tests)
- [E2E Tests](#e2e-tests)
- [Writing E2E Tests](#writing-e2e-tests)
- [Documentation](#documentation)
- [Other commands](#other-commands)
- [Recommended Editors/IDEs](#recommended-editorsides)
@@ -87,61 +90,114 @@ Requirements
------------
- [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com)
- Ensure you're running node `v12.x` or `v14.x` and yarn >= `v1.x`
- Ensure you're running node `v12.x`, `v14.x` or `v16.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.
Installing
----------
- `yarn run global` to install the required global dependencies
- `yarn install` to install the local dependencies
### Configuring
Default configuration file is located in `src/environments/` folder.
Default runtime configuration file is located in `config/` folder. These configurations can be changed without rebuilding the distribution.
To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point.
To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point.
- Create a new `environment.dev.ts` file in `src/environments/` for a `development` environment;
- Create a new `environment.prod.ts` file in `src/environments/` for a `production` environment;
- Create a new `config.(dev or development).yml` file in `config/` for a `development` environment;
- Create a new `config.(prod or production).yml` file in `config/` for a `production` environment;
The server settings can also be overwritten using an environment file.
The settings can also be overwritten using an environment file or environment variables.
This file should be called `.env` and be placed in the project root.
The following settings can be overwritten in this file:
The following non-convention settings:
```bash
DSPACE_HOST # The host name of the angular application
DSPACE_PORT # The port number of the angular application
DSPACE_NAMESPACE # The namespace of the angular application
DSPACE_SSL # Whether the angular application uses SSL [true/false]
```
DSPACE_REST_HOST # The host name of the REST application
DSPACE_REST_PORT # The port number of the REST application
DSPACE_REST_NAMESPACE # The namespace of the REST application
DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false]
All other settings can be set using the following convention for naming the environment variables:
1. replace all `.` with `_`
2. convert all characters to upper case
3. prefix with `DSPACE_`
e.g.
```bash
# The host name of the REST application
rest.host => DSPACE_REST_HOST
# The port number of the REST application
rest.port => DSPACE_REST_PORT
# The namespace of the REST application
rest.nameSpace => DSPACE_REST_NAMESPACE
# Whether the angular REST uses SSL [true/false]
rest.ssl => DSPACE_REST_SSL
cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT
auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE
```
The equavelant to the non-conventional legacy settings:
```bash
DSPACE_UI_HOST => DSPACE_HOST
DSPACE_UI_PORT => DSPACE_PORT
DSPACE_UI_NAMESPACE => DSPACE_NAMESPACE
DSPACE_UI_SSL => DSPACE_SSL
```
The same settings can also be overwritten by setting system environment variables instead, E.g.:
```bash
export DSPACE_HOST=api7.dspace.org
export DSPACE_UI_PORT=4200
```
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides **`environment.(prod, dev or test).ts`** overrides **`environment.common.ts`**
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`**
These configuration sources are collected **at build time**, and written to `src/environments/environment.ts`. At runtime the configuration is fixed, and neither `.env` nor the process' environment will be consulted.
These configuration sources are collected **at run time**, and written to `dist/browser/assets/config.json` for production and `src/app/assets/config.json` for development.
The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`.
#### Buildtime Configuring
Buildtime configuration must defined before build in order to include in transpiled JavaScript. This is primarily for the server. These settings can be found under `src/environment/` folder.
To override the default configuration values for development, create local file that override the build time parameters you need to change.
- Create a new `environment.(dev or development).ts` file in `src/environment/` for a `development` environment;
If needing to update default configurations values for production, update local file that override the build time parameters you need to change.
- Update `environment.production.ts` file in `src/environment/` for a `production` environment;
The environment object is provided for use as import in code and is extended with he runtime configuration on bootstrap of the application.
> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap.
#### Using environment variables in code
To use environment variables in a UI component, use:
```typescript
import { environment } from '../environment.ts';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
...
constructor(@Inject(APP_CONFIG) private appConfig: AppConfig) {}
...
```
This file is generated by the script located in `scripts/set-env.ts`. This script will run automatically before every build, or can be manually triggered using the appropriate `config` script in `package.json`
or
```typescript
import { environment } from '../environment.ts';
```
Running the app
---------------
@@ -152,7 +208,7 @@ After you have installed all dependencies you can now run the app. Run `yarn run
When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload.
To build the app for production and start the server run:
To build the app for production and start the server (in one command) run:
```bash
yarn start
@@ -166,6 +222,10 @@ yarn run build:prod
```
This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`.
After building the app for production, it can be started by running:
```bash
yarn run serve:ssr
```
### Running the application with Docker
NOTE: At this time, we do not have production-ready Docker images for DSpace.
@@ -209,34 +269,89 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
### Unit Tests
Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
The default browser is Google Chrome.
Place your tests in the same location of the application source code files that they test.
Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
and run: `yarn run test`
and run: `yarn test`
### E2E test
If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file:`./protractor.conf.js` Protractor is installed as 'local' as a dev dependency.
### E2E Tests
If you are going to use a remote test enviroment you need to edit the './e2e//protractor.conf.js'. Follow the instructions you will find inside it.
E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Configuration for cypress can be found in the `cypress.json` file in the root directory.
The default browser is Google Chrome.
The test files can be found in the `./cypress/integration/` folder.
Place your tests at the following path: `./e2e`
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.
* 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:
```
DSPACE_REST_SSL = false
DSPACE_REST_HOST = localhost
DSPACE_REST_PORT = 8080
```
2. Your backend MUST include our [Entities Test Data set](https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data). Some tests run against a specific Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set.
* (Recommended) The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data
* Alternatively, the Entities Test Data set may be installed via a simple SQL import (e. g. `psql -U dspace < dspace7-entities-data.sql`). See instructions in link above.
and run: `ng e2e`
After performing the above setup, you can run the e2e tests using
```
ng e2e
````
NOTE: By default these tests will run against the REST API backend configured via environment variables or in `config.prod.yml`. If you'd rather it use `config.dev.yml`, just set the NODE_ENV environment variable like this:
```
NODE_ENV=development ng e2e
```
### Continuous Integration (CI) Test
The `ng e2e` command will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results.
To run all the tests (e.g.: to run tests with Continuous Integration software) you can execute:`yarn run ci` Keep in mind that this command prerequisites are the sum of unit test and E2E tests.
#### Writing E2E Tests
All E2E tests must be created under the `./cypress/integration/` folder, and must end in `.spec.ts`. Subfolders are allowed.
* The easiest way to start creating new tests is by running `ng e2e`. This builds the app and brings up Cypress.
* From here, if you are editing an existing test file, you can either open it in your IDE or run it first to see what it already does.
* To create a new test file, click `+ New Spec File`. Choose a meaningful name ending in `spec.ts` (Please make sure it ends in `.ts` so that it's a Typescript file, and not plain Javascript)
* Start small. Add a basic `describe` and `it` which just [cy.visit](https://docs.cypress.io/api/commands/visit) the page you want to test. For example:
```
describe('Community/Collection Browse Page', () => {
it('should exist as a page', () => {
cy.visit('/community-list');
});
});
```
* Run your test file from the Cypress window. This starts the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) in a new browser window.
* In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_.
* From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page.
* Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector
* It's generally best not to rely on attributes like `class` and `id` in tests, as those are likely to change later on. Instead, you can add a `data-test` attribute to makes it clear that it's required for a test.
* Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc.
* When running with server-side rendering enabled, the client first receives HTML without the JS; only once the page is rendered client-side do some elements (e.g. a button that toggles a Bootstrap dropdown) become fully interactive. This can trip up Cypress in some cases as it may try to `click` or `type` in an element that's not fully loaded yet, causing tests to fail.
* To work around this issue, define the attributes you use for Cypress selectors as `[attr.data-test]="'button' | ngBrowserOnly"`. This will only show the attribute in CSR HTML, forcing Cypress to wait until CSR is complete before interacting with the element.
* Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions.
* Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly.
* Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests.
_Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can help prompt/autocomplete your Cypress commands._
More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress.
### Learning how to build tests
See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips.
Documentation
--------------
See [`./docs`](docs) for further documentation.
Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/
Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase.
### Building code documentation
@@ -259,8 +374,6 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've
- Free
- [Visual Studio Code](https://code.visualstudio.com/)
- [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome)
- [Atom](https://atom.io/)
- [TypeScript plugin](https://atom.io/packages/atom-typescript)
- Paid
- [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/)
- [Sublime Text](http://www.sublimetext.com/3)
@@ -276,101 +389,85 @@ File Structure
```
dspace-angular
├── README.md * This document
├── app.yaml * Application manifest file
├── config * Folder for configuration files
   ├── environment.default.js * Default configuration files
   └── environment.test.js * Test configuration files
├── config *
│ └── config.yml * Default app config
├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests
├── downloads *
├── fixtures * Folder for e2e/integration test files
│ ├── integration * Folder for any fixtures needed by e2e tests
│ ├── plugins * Folder for Cypress plugins (if any)
│ ├── support * Folder for global e2e test actions/commands (run for all tests)
│ └── tsconfig.json * TypeScript configuration file for e2e tests
├── docker * See docker/README.md for details
│ ├── cli.assetstore.yml *
│ ├── cli.ingest.yml *
│ ├── cli.yml *
│ ├── db.entities.yml *
│ ├── docker-compose-ci.yml *
│ ├── docker-compose-rest.yml *
│ ├── docker-compose.yml *
│ └── README.md *
├── docs * Folder for documentation
├── e2e * Folder for e2e test files
│   ├── app.e2e-spec.ts *
   ├── app.po.ts *
   ├── pagenotfound *
   │   ├── pagenotfound.e2e-spec.ts *
   │   └── pagenotfound.po.ts *
   └── tsconfig.json * TypeScript configuration file for e2e tests
│ └── Configuration.md * Configuration documentation
├── scripts *
├── merge-i18n-files.ts *
├── serve.ts *
├── sync-i18n-files.ts *
├── test-rest.ts *
└── webpack.js *
├── src * The source of the application
│ ├── app * The source code of the application, subdivided by module/page.
│ ├── assets * Folder for static resources
│ │ ├── fonts * Folder for fonts
│ │ ├── i18n * Folder for i18n translations
│ │ └── images * Folder for images
│ ├── backend * Folder containing a mock of the REST API, hosted by the express server
│ ├── config *
│ ├── environments *
│ │ ├── environment.production.ts * Production configuration files
│ │ ├── environment.test.ts * Test configuration files
│ │ └── environment.ts * Default (development) configuration files
│ ├── mirador-viewer *
│ ├── modules *
│ ├── ngx-translate-loaders *
│ ├── styles * Folder containing global styles
│ ├── themes * Folder containing available themes
│ │ ├── custom * Template folder for creating a custom theme
│ │ └── dspace * Default 'dspace' theme
│ ├── index.csr.html * The index file for client side rendering fallback
│ ├── index.html * The index file
│ ├── main.browser.ts * The bootstrap file for the client
│ ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
│ ├── polyfills.ts *
│ ├── robots.txt * The robots.txt file
│ ├── test.ts *
│ └── typings.d.ts *
├── webpack *
│ ├── helpers.ts * Webpack helpers
│ ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for browser build
│ ├── webpack.common.ts * Webpack (https://webpack.github.io/) common build config
│ ├── webpack.mirador.config.ts * Webpack (https://webpack.github.io/) config for mirador config build
│ ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for prod build
│ └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build
├── angular.json * Angular CLI (https://angular.io/cli) configuration
├── cypress.json * Cypress Test (https://www.cypress.io/) configuration
├── Dockerfile *
├── karma.conf.js * Karma configuration file for Unit Test
├── LICENSE *
├── LICENSES_THIRD_PARTY *
├── nodemon.json * Nodemon (https://nodemon.io/) configuration
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
├── postcss.config.js * PostCSS (http://postcss.org/) configuration file
├── protractor.conf.js *
├── resources * Folder for static resources
│   ├── data * Folder for static data
│   │   └── en * Folder for i18n English data
│   ├── i18n * Folder for i18n translations
│   │   └── en.json * i18n translations for English
│   └── images * Folder for images
│   ├── dspace-logo-old.png *
│   ├── dspace-logo.png *
│   └── favicon.ico *
├── rollup.config.js * Rollup (http://rollupjs.org/) configuration
├── spec-bundle.js *
├── src * The source of the application
│   ├── app *
│   │   ├── app-routing.module.ts *
│   │   ├── app.component.html *
│   │   ├── app.component.scss *
│   │   ├── app.component.spec.ts *
│   │   ├── app.component.ts *
│   │   ├── app.effects.ts *
│   │   ├── app.module.ts *
│   │   ├── app.reducer.ts *
│   │   ├── browser-app.module.ts * The root module for the client
│   │   ├── +collection-page * Lazily loaded route for collection module
│   │   ├── +community-page * Lazily loaded route for community module
│   │   ├── core *
│   │   ├── header *
│   │   ├── +home * Lazily loaded route for home module
│   │   ├── +item-page * Lazily loaded route for item module
│   │   ├── object-list *
│   │   ├── pagenotfound *
│   │   ├── server-app.module.ts * The root module for the server
│   │   ├── shared *
│   │   ├── store.actions.ts *
│   │   ├── store.effects.ts *
│   │   ├── thumbnail *
│   │   └── typings.d.ts * File that allows you to add custom typings for libraries without TypeScript support
│   ├── backend * Folder containing a mock of the REST API, hosted by the express server
│   │   ├── api.ts *
│   │   ├── cache.ts *
│   │   ├── data *
│   │   └── db.ts *
│   ├── config *
│   │   ├── cache-config.interface.ts *
│   │   ├── config.interface.ts *
│   │   ├── global-config.interface.ts *
│   │   ├── server-config.interface.ts *
│   │   └── universal-config.interface.ts *
│   ├── config.ts * File that loads environmental and shareable settings and makes them available to app components
│   ├── index.csr.html * The index file for client side rendering fallback
│   ├── index.html * The index file
│   ├── main.browser.ts * The bootstrap file for the client
│   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
│   ├── modules *
│   │   ├── cookies *
│   │   ├── data-loader *
│   │   ├── transfer-http *
│   │   ├── transfer-state *
│   │   ├── transfer-store *
│   │   └── translate-universal-loader.ts *
│   ├── routes.ts * The routes file for the server
│   ├── styles * Folder containing global styles
│   │   ├── _mixins.scss *
│   │   └── variables.scss * Global sass variables file
│   ├── tsconfig.browser.json * TypeScript config for the client build
│   ├── tsconfig.server.json * TypeScript config for the server build
│   └── tsconfig.test.json * TypeScript config for the test build
├── tsconfig.json * TypeScript config
├── postcss.config.js * PostCSS (http://postcss.org/) configuration
├── README.md * This document
├── SECURITY.md *
├── server.ts * Angular Universal Node.js Express server
├── tsconfig.app.json * TypeScript config for browser (app)
├── tsconfig.json * TypeScript common config
├── tsconfig.server.json * TypeScript config for server
├── tsconfig.spec.json * TypeScript config for tests
├── tsconfig.ts-node.json * TypeScript config for using ts-node directly
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
├── typedoc.json * TYPEDOC configuration
├── webpack * Webpack (https://webpack.github.io/) config directory
│   ├── webpack.aot.js * Webpack (https://webpack.github.io/) config for AoT build
│   ├── webpack.client.js * Webpack (https://webpack.github.io/) config for client build
│   ├── webpack.common.js *
│   ├── webpack.prod.js * Webpack (https://webpack.github.io/) config for production build
│   ├── webpack.server.js * Webpack (https://webpack.github.io/) config for server build
│   └── webpack.test.js * Webpack (https://webpack.github.io/) config for test build
├── webpack.config.ts *
└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock)
```
@@ -468,4 +565,8 @@ DSpace uses GitHub to track issues:
License
-------
This project's source code is made available under the DSpace BSD License: http://www.dspace.org/license
DSpace source code is freely available under a standard [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause).
The full license is available in the [LICENSE](LICENSE) file or online at http://www.dspace.org/license/
DSpace uses third-party libraries which may be distributed under different licenses. Those licenses are listed
in the [LICENSES_THIRD_PARTY](LICENSES_THIRD_PARTY) file.

View File

@@ -17,7 +17,6 @@
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"extractCss": true,
"preserveSymlinks": true,
"customWebpackConfig": {
"path": "./webpack/webpack.browser.ts",
@@ -67,10 +66,27 @@
"scripts": []
},
"configurations": {
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
},
{
"replace": "src/config/store/devtools.ts",
"with": "src/config/store/devtools.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
@@ -98,6 +114,9 @@
"port": 4000
},
"configurations": {
"development": {
"browserTarget": "dspace-angular:build:development"
},
"production": {
"browserTarget": "dspace-angular:build:production"
}
@@ -139,26 +158,24 @@
}
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
"configurations": {
"test": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.test.ts"
}
]
}
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"builder": "@cypress/schematic:cypress",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "dspace-angular:serve"
"devServerTarget": "dspace-angular:serve",
"watch": true,
"headless": false
},
"configurations": {
"production": {
@@ -176,16 +193,27 @@
}
},
"outputPath": "dist/server",
"main": "src/main.server.ts",
"main": "server.ts",
"tsConfig": "tsconfig.server.json"
},
"configurations": {
"development": {
"sourceMap": true,
"optimization": false
},
"production": {
"sourceMap": false,
"optimization": {
"scripts": false,
"styles": true
"optimization": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
},
{
"replace": "src/config/store/devtools.ts",
"with": "src/config/store/devtools.prod.ts"
}
]
}
}
},
@@ -215,9 +243,40 @@
"configurations": {
"production": {}
}
},
"cypress-run": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "dspace-angular:serve"
},
"configurations": {
"production": {
"devServerTarget": "dspace-angular:serve:production"
}
}
},
"cypress-open": {
"builder": "@cypress/schematic:cypress",
"options": {
"watch": true,
"headless": false
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "dspace-angular"
"defaultProject": "dspace-angular",
"cli": {
"analytics": false,
"defaultCollection": "@angular-eslint/schematics"
}
}

2
config/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.*.yml
!config.example.yml

248
config/config.example.yml Normal file
View File

@@ -0,0 +1,248 @@
# NOTE: will log all redux actions and transfers in console
debug: false
# Angular Universal server settings
# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg.
ui:
ssl: false
host: localhost
port: 4000
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /
# The rateLimiter settings limit each IP to a 'max' of 500 requests per 'windowMs' (1 minute).
rateLimiter:
windowMs: 60000 # 1 minute
max: 500 # limit each IP to 500 requests per windowMs
# The REST API server settings
# NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
rest:
ssl: true
host: api7.dspace.org
port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server
# Caching settings
cache:
# NOTE: how long should objects be cached for by default
msToLive:
default: 900000 # 15 minutes
control: max-age=60 # revalidate browser
autoSync:
defaultTime: 0
maxBufferSize: 100
timePerMethod:
PATCH: 3 # time in seconds
# Authentication settings
auth:
# Authentication UI settings
ui:
# the amount of time before the idle warning is shown
timeUntilIdle: 900000 # 15 minutes
# the amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: 300000 # 5 minutes
# Authentication REST settings
rest:
# If the rest token expires in less than this amount of time, it will be refreshed automatically.
# This is independent from the idle warning.
timeLeftBeforeTokenRefresh: 120000 # 2 minutes
# Form settings
form:
# NOTE: Map server-side validators to comparative Angular form validators
validatorMap:
required: required
regex: pattern
# Notification settings
notifications:
rtl: false
position:
- top
- right
maxStack: 8
# NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically
timeOut: 5000 # 5 second
clickToClose: true
# NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale'
animate: scale
# Submission settings
submission:
autosave:
# NOTE: which metadata trigger an autosave
metadata: []
# NOTE: after how many time (milliseconds) submission is saved automatically
# eg. timer: 5 * (1000 * 60); // 5 minutes
timer: 0
icons:
metadata:
# NOTE: example of configuration
# # NOTE: metadata name
# - name: dc.author
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
# style: fas fa-user
- name: dc.author
style: fas fa-user
# default configuration
- name: default
style: ''
authority:
confidence:
# NOTE: example of configuration
# # NOTE: confidence value
# - name: dc.author
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
# style: fa-user
- value: 600
style: text-success
- value: 500
style: text-info
- value: 400
style: text-warning
# default configuration
- value: default
style: text-muted
# Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: en
# Languages. DSpace Angular holds a message catalog for each of the following languages.
# When set to active, users will be able to switch to the use of this language in the user interface.
languages:
- code: en
label: English
active: true
- code: cs
label: Čeština
active: true
- code: de
label: Deutsch
active: true
- code: es
label: Español
active: true
- code: fr
label: Français
active: true
- code: gd
label: Gàidhlig
active: true
- code: lv
label: Latviešu
active: true
- code: hu
label: Magyar
active: true
- code: nl
label: Nederlands
active: true
- code: pt-PT
label: Português
active: true
- code: pt-BR
label: Português do Brasil
active: true
- code: fi
label: Suomi
active: true
- code: tr
label: Türkçe
active: true
- code: bn
label: বাংলা
active: true
# Browse-By Pages
browseBy:
# Amount of years to display using jumps of one year (current year - oneYearLimit)
oneYearLimit: 10
# Limit for years to display using jumps of five years (current year - fiveYearLimit)
fiveYearLimit: 30
# The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900
# Item Config
item:
edit:
undoTimeout: 10000 # 10 seconds
# Show the item access status label in items lists
showAccessStatuses: false
# Collection Page Config
collection:
edit:
undoTimeout: 10000 # 10 seconds
# Theme Config
themes:
# Add additional themes here. In the case where multiple themes match a route, the first one
# in this list will get priority. It is advisable to always have a theme that matches
# every route as the last one
#
# # 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
# - name: 'custom',
# handle: '10673/1233'
#
# # 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
# # and/or items within it
# - name: 'custom',
# regex: 'collections\/e8043bc2.*'
#
# # 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
# - name: 'custom',
# uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
#
# # 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.
# - name: 'custom-A',
# extends: 'custom-B',
# # Any of the matching properties above can be used
# handle: '10673/34'
#
# - name: 'custom-B',
# extends: 'custom',
# handle: '10673/12'
#
# # A theme with only a name will match every route
# name: 'custom'
#
# # This theme will use the default bootstrap styling for DSpace components
# - name: BASE_THEME_NAME
#
- name: dspace
headTags:
- tagName: link
attributes:
rel: icon
href: assets/dspace/images/favicons/favicon.ico
sizes: any
- tagName: link
attributes:
rel: icon
href: assets/dspace/images/favicons/favicon.svg
type: image/svg+xml
- tagName: link
attributes:
rel: apple-touch-icon
href: assets/dspace/images/favicons/apple-touch-icon.png
- tagName: link
attributes:
rel: manifest
href: assets/dspace/images/favicons/manifest.webmanifest
# The default bundles that should always be displayed as suggestions when you upload a new bundle
bundle:
- standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ]
# Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video').
# For images, this enables a gallery viewer where you can zoom or page through images.
# For videos, this enables embedded video streaming
mediaViewer:
image: false
video: false

5
config/config.yml Normal file
View File

@@ -0,0 +1,5 @@
rest:
ssl: true
host: api7.dspace.org
port: 443
nameSpace: /server

25
cypress.json Normal file
View File

@@ -0,0 +1,25 @@
{
"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"
}
}

2
cypress/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
screenshots/
videos/

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,15 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Breadcrumbs', () => {
it('should pass accessibility tests', () => {
// Visit an Item, as those have more breadcrumbs
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
// Wait for breadcrumbs to be visible
cy.get('ds-breadcrumbs').should('be.visible');
// Analyze <ds-breadcrumbs> for accessibility
testA11y('ds-breadcrumbs');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Author', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/author');
// Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Date Issued', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/dateissued');
// Wait for <ds-browse-by-date-page> to be visible
cy.get('ds-browse-by-date-page').should('be.visible');
// Analyze <ds-browse-by-date-page> for accessibility
testA11y('ds-browse-by-date-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Subject', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/subject');
// Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Title', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/title');
// Wait for <ds-browse-by-title-page> to be visible
cy.get('ds-browse-by-title-page').should('be.visible');
// Analyze <ds-browse-by-title-page> for accessibility
testA11y('ds-browse-by-title-page');
});
});

View File

@@ -0,0 +1,15 @@
import { TEST_COLLECTION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Collection Page', () => {
it('should pass accessibility tests', () => {
cy.visit('/collections/' + TEST_COLLECTION);
// <ds-collection-page> tag must be loaded
cy.get('ds-collection-page').should('exist');
// Analyze <ds-collection-page> for accessibility issues
testA11y('ds-collection-page');
});
});

View File

@@ -0,0 +1,32 @@
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');
});
});

View File

@@ -0,0 +1,25 @@
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
);
});
});

View File

@@ -0,0 +1,15 @@
import { TEST_COMMUNITY } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Community Page', () => {
it('should pass accessibility tests', () => {
cy.visit('/communities/' + TEST_COMMUNITY);
// <ds-community-page> tag must be loaded
cy.get('ds-community-page').should('exist');
// Analyze <ds-community-page> for accessibility issues
testA11y('ds-community-page',);
});
});

View File

@@ -0,0 +1,32 @@
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');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Footer', () => {
it('should pass accessibility tests', () => {
cy.visit('/');
// Footer must first be visible
cy.get('ds-footer').should('be.visible');
// Analyze <ds-footer> for accessibility
testA11y('ds-footer');
});
});

View File

@@ -0,0 +1,19 @@
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
],
});
});
});

View File

@@ -0,0 +1,19 @@
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');
});
});

View File

@@ -0,0 +1,32 @@
import { testA11y } from 'cypress/support/utils';
describe('Homepage', () => {
beforeEach(() => {
// All tests start with visiting homepage
cy.visit('/');
});
it('should display translated title "DSpace Angular :: Home"', () => {
cy.title().should('eq', 'DSpace Angular :: Home');
});
it('should contain a news section', () => {
cy.get('ds-home-news').should('be.visible');
});
it('should have a working search box', () => {
const queryString = 'test';
cy.get('[data-test="search-box"]').type(queryString);
cy.get('[data-test="search-button"]').click();
cy.url().should('include', '/search');
cy.url().should('include', 'query=' + encodeURI(queryString));
});
it('should pass accessibility tests', () => {
// Wait for homepage tag to appear
cy.get('ds-home-page').should('be.visible');
// Analyze <ds-home-page> for accessibility issues
testA11y('ds-home-page');
});
});

View File

@@ -0,0 +1,31 @@
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
);
});
});

View File

@@ -0,0 +1,38 @@
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');
});
});

View File

@@ -0,0 +1,126 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
const page = {
openLoginMenu() {
// Click the "Log In" dropdown menu in header
cy.get('ds-themed-navbar [data-test="login-menu"]').click();
},
openUserMenu() {
// Once logged in, click the User menu in header
cy.get('ds-themed-navbar [data-test="user-menu"]').click();
},
submitLoginAndPasswordByPressingButton(email, password) {
// Enter email
cy.get('ds-themed-navbar [data-test="email"]').type(email);
// Enter password
cy.get('ds-themed-navbar [data-test="password"]').type(password);
// Click login button
cy.get('ds-themed-navbar [data-test="login-button"]').click();
},
submitLoginAndPasswordByPressingEnter(email, password) {
// 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-navbar [data-test="password"]').type(password);
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}');
},
submitLogoutByPressingButton() {
// This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button
cy.get('ds-themed-navbar [data-test="logout-button"]').click();
// Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes)
cy.wait('@logout');
}
};
describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => {
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
cy.visit(ENTITYPAGE);
// Login menu should exist
cy.get('ds-log-in').should('exist');
// Login, and the <ds-log-in> tag should no longer exist
page.openLoginMenu();
cy.get('.form-login').should('be.visible');
page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.get('ds-log-in').should('not.exist');
// Verify we are still on the same page
cy.url().should('include', ENTITYPAGE);
// Open user menu, verify user menu & logout button now available
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
cy.get('ds-log-out').should('be.visible');
});
it('should login when clicking enter key & stay on same page', () => {
cy.visit('/home');
// Open login menu in header & verify <ds-log-in> tag is visible
page.openLoginMenu();
cy.get('.form-login').should('be.visible');
// Login, and the <ds-log-in> tag should no longer exist
page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.get('.form-login').should('not.exist');
// Verify we are still on homepage
cy.url().should('include', '/home');
// Open user menu, verify user menu & logout button now available
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
cy.get('ds-log-out').should('be.visible');
});
it('should support logout', () => {
// First authenticate & access homepage
cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.visit('/');
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
cy.get('ds-log-in').should('not.exist');
cy.get('ds-log-out').should('exist');
// Click logout button
page.openUserMenu();
page.submitLogoutByPressingButton();
// Verify ds-log-in tag now exists
cy.get('ds-log-in').should('exist');
cy.get('ds-log-out').should('not.exist');
});
it('should allow new user registration', () => {
cy.visit('/');
page.openLoginMenu();
// Registration link should be visible
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible');
// Click registration link & you should go to registration page
cy.get('ds-themed-navbar [data-test="register"]').click();
cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist');
});
it('should allow forgot password', () => {
cy.visit('/');
page.openLoginMenu();
// Forgot password link should be visible
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible');
// Click link & you should go to Forgot Password page
cy.get('ds-themed-navbar [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist');
});
});

View File

@@ -0,0 +1,149 @@
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';
describe('My DSpace page', () => {
it('should display recent submissions and pass accessibility tests', () => {
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
cy.get('ds-my-dspace-page').should('exist');
// At least one recent submission should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('.filter-toggle').click({ multiple: true });
// Analyze <ds-my-dspace-page> for accessibility issues
testA11y(
{
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', () => {
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
cy.get('ds-my-dspace-page').should('exist');
// Click button in sidebar to display detailed view
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
cy.get('ds-object-detail').should('exist');
// Analyze <ds-search-page> for accessibility issues
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
it('should let you start a new submission & edit in-progress submissions', () => {
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
// Open the New Submission dropdown
cy.get('button[data-test="submission-dropdown"]').click();
// Click on the "Item" type in that dropdown
cy.get('#entityControlsDropdownMenu button[title="none"]').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(TEST_SUBMIT_COLLECTION_NAME);
// Click on the button matching that known Collection name
cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click();
// 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', TEST_SUBMIT_COLLECTION_NAME);
// 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
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];
// Click the "Save for Later" button to save this submission
cy.get('ds-submission-form-footer [data-test="save-for-later"]').click();
// "Save for Later" should send us to MyDSpace
cy.url().should('include', '/mydspace');
// Close any open notifications, to make sure they don't get in the way of next steps
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 created 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');
// Click the Edit button for this in-progress submission
cy.get('#edit_' + id).click();
// Should send us back to the submission form
cy.url().should('include', '/workspaceitems/' + id + '/edit');
// Discard our new submission by clicking Discard in Submission form & confirming
cy.get('ds-submission-form-footer [data-test="discard"]').click();
cy.get('button#discard_submit').click();
// Discarding should send us back to MyDSpace
cy.url().should('include', '/mydspace');
});
});
it('should let you import from external sources', () => {
cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.visit('/mydspace');
// Open the New Import dropdown
cy.get('button[data-test="import-dropdown"]').click();
// Click on the "Item" type in that dropdown
cy.get('#importControlsDropdownMenu button[title="none"]').click();
// New URL should include /import-external, as we've moved to the import page
cy.url().should('include', '/import-external');
// The external import searchbox should be visible
cy.get('ds-submission-import-external-searchbar').should('be.visible');
});
});

View File

@@ -0,0 +1,13 @@
describe('PageNotFound', () => {
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
cy.get('ds-pagenotfound').should('exist');
});
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
cy.visit('/home');
cy.get('ds-pagenotfound').should('not.exist');
});
});

View File

@@ -0,0 +1,66 @@
import { TEST_SEARCH_TERM } from 'cypress/support';
const page = {
fillOutQueryInNavBar(query) {
// Click the magnifying glass
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
// Fill out a query in input that appears
cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query);
},
submitQueryByPressingEnter() {
cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}');
},
submitQueryByPressingIcon() {
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
}
};
describe('Search from Navigation Bar', () => {
// NOTE: these tests currently assume this query will return results!
const query = TEST_SEARCH_TERM;
it('should go to search page with correct query if submitted (from home)', () => {
cy.visit('/');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter();
// New URL should include query param
cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
});
it('should go to search page with correct query if submitted (from search)', () => {
cy.visit('/search');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter();
// New URL should include query param
cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
});
it('should allow user to also submit query by clicking icon', () => {
cy.visit('/');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query);
page.submitQueryByPressingIcon();
// New URL should include query param
cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
});
});

View File

@@ -0,0 +1,70 @@
import { Options } from 'cypress-axe';
import { TEST_SEARCH_TERM } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => {
it('should redirect to the correct url when query was set and submit button was triggered', () => {
const queryString = 'Another interesting query string';
cy.visit('/search');
// Type query in searchbox & click search button
cy.get('[data-test="search-box"]').type(queryString);
cy.get('[data-test="search-button"]').click();
cy.url().should('include', 'query=' + encodeURI(queryString));
});
it('should load results and pass accessibility tests', () => {
cy.visit('/search?query=' + TEST_SEARCH_TERM);
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
// <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-search-page> for accessibility issues
testA11y(
{
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', () => {
cy.visit('/search?query=' + TEST_SEARCH_TERM);
// Click button in sidebar to display grid view
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
// <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist');
// At least one grid object (card) should be displayed
cy.get('[data-test="grid-object"]').should('be.visible');
// Analyze <ds-search-page> for accessibility issues
testA11y('ds-search-page',
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
});
});

View File

@@ -0,0 +1,135 @@
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');
});
});

35
cypress/plugins/index.ts Normal file
View File

@@ -0,0 +1,35 @@
const fs = require('fs');
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
module.exports = (on, config) => {
on('task', {
// Define "log" and "table" tasks, used for logging accessibility errors during CI
// Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file
log(message: string) {
console.log(message);
return null;
},
table(message: string) {
console.table(message);
return null;
},
// 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.
readUIConfig() {
// Check if we have a config.json in the src/assets. If so, use that.
// This is where it's written when running "ng e2e" or "yarn serve"
if (fs.existsSync('./src/assets/config.json')) {
return fs.readFileSync('./src/assets/config.json', 'utf8');
// Otherwise, check the dist/browser/assets
// This is where it's written when running "serve:ssr", which is what CI uses to start the frontend
} else if (fs.existsSync('./dist/browser/assets/config.json')) {
return fs.readFileSync('./dist/browser/assets/config.json', 'utf8');
}
return null;
}
});
};

View File

@@ -0,0 +1,83 @@
// ***********************************************
// This File is for Custom Cypress commands.
// See docs at https://docs.cypress.io/api/cypress-api/custom-commands
// ***********************************************
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
import { FALLBACK_TEST_REST_BASE_URL } from '.';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work
// tslint:disable-next-line:no-namespace
declare global {
namespace Cypress {
interface Chainable<Subject = any> {
/**
* Login to backend before accessing the next page. Ensures that the next
* call to "cy.visit()" will be authenticated as this user.
* @param email email to login as
* @param password password to login as
*/
login(email: string, password: string): typeof login;
}
}
}
/**
* Login user via REST API directly, and pass authentication token to UI via
* the UI's dsAuthInfo cookie.
* @param email email to login as
* @param password password to login as
*/
function login(email: string, password: string): void {
// Cypress doesn't have access to the running application in Node.js.
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
// Instead, we'll read our running application's config.json, which contains the configs &
// is regenerated at runtime each time the Angular UI application starts up.
cy.task('readUIConfig').then((str: string) => {
// Parse config into a JSON object
const config = JSON.parse(str);
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
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
cy.request({
method: 'POST',
url: baseRestUrl + '/api/authn/login',
headers: { 'X-XSRF-TOKEN' : csrfToken},
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);
// We expect to have a valid authorization header returned (with our auth token)
expect(resp.headers).to.have.property('authorization');
// Initialize our AuthTokenInfo object from the authorization header.
const authheader = resp.headers.authorization as string;
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie
// This ensures the UI will recognize we are logged in on next "visit()"
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
});
});
});
}
// Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login);

63
cypress/support/index.ts Normal file
View File

@@ -0,0 +1,63 @@
// ***********************************************************
// 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}');
});
// 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';

44
cypress/support/utils.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Result } from 'axe-core';
import { Options } from 'cypress-axe';
// Log violations to terminal/commandline in a table format.
// Uses 'log' and 'table' tasks defined in ../plugins/index.ts
// Borrowed from https://github.com/component-driven/cypress-axe#in-your-spec-file
function terminalLog(violations: Result[]) {
cy.task(
'log',
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`
);
// pluck specific keys to keep the table readable
const violationData = violations.map(
({ id, impact, description, helpUrl, nodes }) => ({
id,
impact,
description,
helpUrl,
nodes: nodes.length,
html: nodes.map(node => node.html)
})
);
// Print violations as an array, since 'node.html' above often breaks table alignment
cy.task('log', violationData);
// Optionally, uncomment to print as a table
// cy.task('table', violationData);
}
// Custom "testA11y()" method which checks accessibility using cypress-axe
// while also ensuring any violations are logged to the terminal (see terminalLog above)
// This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load
export const testA11y = (context?: any, options?: Options) => {
cy.injectAxe();
cy.configureAxe({
rules: [
// Disable color contrast checks as they are inaccurate / result in a lot of false positives
// See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast
{ id: 'color-contrast', enabled: false },
]
});
cy.checkA11y(context, options, terminalLog);
};

13
cypress/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"include": [
"**/*.ts"
],
"compilerOptions": {
"types": [
"cypress",
"cypress-axe",
"node"
]
}
}

View File

@@ -4,6 +4,20 @@
:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario.
***
## 'Dockerfile' in root directory
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 .
```
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.
```
docker push dspace/dspace-angular:dspace-7_x
```
## docker directory
- docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
@@ -15,10 +29,6 @@
- Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container.
- cli.assetstore.yml
- Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing.
- environment.dev.ts
- Environment file for running DSpace Angular in Docker
- local.cfg
- Environment file for running the DSpace 7 REST API in Docker.
## To refresh / pull DSpace images from Dockerhub

View File

@@ -35,6 +35,6 @@ services:
tar xvfz /tmp/assetstore.tar.gz
fi
/dspace/bin/dspace index-discovery
/dspace/bin/dspace index-discovery -b
/dspace/bin/dspace oai import
/dspace/bin/dspace oai clean-cache

View File

@@ -18,10 +18,19 @@ services:
dspace-cli:
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}"
container_name: dspace-cli
#environment:
environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.
# See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml
# __P__ => "." (e.g. dspace__P__dir => dspace.dir)
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir
dspace__P__dir: /dspace
# db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
solr__P__server: http://dspacesolr:8983/solr
volumes:
- "assetstore:/dspace/assetstore"
- "./local.cfg:/dspace/config/local.cfg"
entrypoint: /dspace/bin/dspace
command: help
networks:

View File

@@ -20,7 +20,7 @@ services:
environment:
# This LOADSQL should be kept in sync with the URL in DSpace/DSpace
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
- LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql
- LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql
dspace:
### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' ####
# Ensure that the database is ready BEFORE starting tomcat

View File

@@ -17,6 +17,19 @@ services:
# DSpace (backend) webapp container
dspace:
container_name: dspace
environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.
# See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml
# __P__ => "." (e.g. dspace__P__dir => dspace.dir)
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir, dspace.server.url and dspace.ui.url
dspace__P__dir: /dspace
dspace__P__server__P__url: http://localhost:8080/server
dspace__P__ui__P__url: http://localhost:4000
# db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
solr__P__server: http://dspacesolr:8983/solr
depends_on:
- dspacedb
image: dspace/dspace:dspace-7_x-test
@@ -29,7 +42,6 @@ services:
tty: true
volumes:
- assetstore:/dspace/assetstore
- "./local.cfg:/dspace/config/local.cfg"
# 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
@@ -51,7 +63,7 @@ services:
# This LOADSQL should be kept in sync with the LOADSQL in
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql
LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql
PGDATA: /pgdata
image: dspace/dspace-postgres-pgcrypto:loadsql
networks:
@@ -64,7 +76,7 @@ services:
dspacesolr:
container_name: dspacesolr
# Uses official Solr image at https://hub.docker.com/_/solr/
image: solr:8.8
image: solr:8.11-slim
# Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on:
- dspace

View File

@@ -13,10 +13,32 @@
version: '3.7'
networks:
dspacenet:
ipam:
config:
# Define a custom subnet for our DSpace network, so that we can easily trust requests from host to container.
# If you customize this value, be sure to customize the 'proxies.trusted.ipranges' env variable below.
- subnet: 172.23.0.0/16
services:
# DSpace (backend) webapp container
dspace:
container_name: dspace
environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.
# See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml
# __P__ => "." (e.g. dspace__P__dir => dspace.dir)
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir, dspace.server.url, dspace.ui.url and dspace.name
dspace__P__dir: /dspace
dspace__P__server__P__url: http://localhost:8080/server
dspace__P__ui__P__url: http://localhost:4000
dspace__P__name: 'DSpace Started with Docker Compose'
# db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
solr__P__server: http://dspacesolr:8983/solr
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0'
image: dspace/dspace:dspace-7_x-test
depends_on:
- dspacedb
@@ -29,7 +51,6 @@ services:
tty: true
volumes:
- assetstore:/dspace/assetstore
- "./local.cfg:/dspace/config/local.cfg"
# 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
@@ -62,7 +83,7 @@ services:
dspacesolr:
container_name: dspacesolr
# Uses official Solr image at https://hub.docker.com/_/solr/
image: solr:8.8
image: solr:8.11-slim
# Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on:
- dspace
@@ -81,15 +102,22 @@ services:
# Keep Solr data directory between reboots
- solr_data:/var/solr/data
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
# * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core
# to the latest configs. If it's a newly created core, this is a no-op.
entrypoint:
- /bin/bash
- '-c'
- |
init-var-solr
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai
precreate-core search /opt/solr/server/solr/configsets/dspace/search
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics
exec solr -f
volumes:
assetstore:

View File

@@ -16,11 +16,15 @@ services:
dspace-angular:
container_name: dspace-angular
environment:
DSPACE_HOST: dspace-angular
DSPACE_NAMESPACE: /
DSPACE_PORT: '4000'
DSPACE_SSL: "false"
image: dspace/dspace-angular:latest
DSPACE_UI_SSL: 'false'
DSPACE_UI_HOST: dspace-angular
DSPACE_UI_PORT: '4000'
DSPACE_UI_NAMESPACE: /
DSPACE_REST_SSL: 'false'
DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:dspace-7_x
build:
context: ..
dockerfile: Dockerfile
@@ -33,5 +37,3 @@ services:
target: 9876
stdin_open: true
tty: true
volumes:
- ./environment.dev.ts:/app/src/environments/environment.dev.ts

View File

@@ -1,18 +0,0 @@
/**
* 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/
*/
// This file is based on environment.template.ts provided by Angular UI
export const environment = {
// Default to using the local REST API (running in Docker)
rest: {
ssl: false,
host: 'localhost',
port: 8080,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server'
}
};

View File

@@ -1,6 +0,0 @@
dspace.dir=/dspace
db.url=jdbc:postgresql://dspacedb:5432/dspace
dspace.server.url=http://localhost:8080/server
dspace.ui.url=http://localhost:4000
dspace.name=DSpace Started with Docker Compose
solr.server=http://dspacesolr:8983/solr

View File

@@ -1,26 +1,30 @@
# Configuration
Default configuration file is located in `src/environments/` folder. All configuration options should be listed in the default configuration file `src/environments/environment.common.ts`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point.
Default configuration file is located at `config/config.yml`. All configuration options should be listed in the default typescript file `src/config/default-app-config.ts`. Please do not change this file directly! To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point.
- Create a new `environment.dev.ts` file in `src/environments/` for `development` environment;
- Create a new `environment.prod.ts` file in `src/environments/` for `production` environment;
- Create a new `config.(dev or development).yml` file in `config/` for `development` environment;
- Create a new `config.(prod or production).yml` file in `config/` for `production` environment;
Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below.
Alternatively, create a desired app config file at an external location and set the path as environment variable `DSPACE_APP_CONFIG_PATH`.
e.g.
```
DSPACE_APP_CONFIG_PATH=/usr/local/dspace/config/config.yml
```
Configuration options can be overridden by setting environment variables.
## Nodejs server
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):
```
export const environment = {
// Angular UI settings.
ui: {
ssl: false,
host: 'localhost',
port: 4000,
nameSpace: '/'
}
};
```yaml
ui:
ssl: false
host: localhost
port: 4000
nameSpace: /
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
@@ -30,21 +34,24 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_PORT=4000
DSPACE_NAMESPACE=/
```
or
```
DSPACE_UI_SSL=true
DSPACE_UI_HOST=localhost
DSPACE_UI_PORT=4000
DSPACE_UI_NAMESPACE=/
```
## DSpace's REST endpoint
dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options:
```
export const environment = {
// The REST API server settings.
rest: {
ssl: true,
host: 'api7.dspace.org',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server'
```yaml
rest:
ssl: true
host: api7.dspace.org
port: 443
nameSpace: /server
}
};
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
@@ -55,6 +62,21 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_REST_NAMESPACE=/server
```
## Environment variable naming convention
Settings can be set using the following convention for naming the environment variables:
1. replace all `.` with `_`
2. convert all characters to upper case
3. prefix with `DSPACE_`
e.g.
```
cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT
auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE
```
## Supporting analytics services other than Google Analytics
This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics.

View File

@@ -1,14 +0,0 @@
const config = require('./protractor.conf').config;
config.capabilities = {
browserName: 'chrome',
chromeOptions: {
args: ['--headless', '--no-sandbox', '--disable-gpu']
}
};
// don't use protractor's webdriver, as it may be incompatible with the installed chrome version
config.directConnect = false;
config.seleniumAddress = 'http://localhost:4444/wd/hub';
exports.config = config;

View File

@@ -1,91 +0,0 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/docs/referenceConf.js
/*global jasmine */
var SpecReporter = require('jasmine-spec-reporter').SpecReporter;
exports.config = {
allScriptsTimeout: 600000,
// -----------------------------------------------------------------
// Uncomment to run tests using a remote Selenium server
//seleniumAddress: 'http://selenium.address:4444/wd/hub',
// Change to 'false' to run tests using a remote Selenium server
directConnect: true,
// Change if the website to test is not on the localhost
baseUrl: 'http://localhost:4000/',
// -----------------------------------------------------------------
specs: [
'./src/**/*.e2e-spec.ts'
],
// -----------------------------------------------------------------
// Browser and Capabilities: PhantomJS
// -----------------------------------------------------------------
// capabilities: {
// 'browserName': 'phantomjs',
// 'version': '',
// 'platform': 'ANY'
// },
// -----------------------------------------------------------------
// Browser and Capabilities: Chrome
// -----------------------------------------------------------------
capabilities: {
'browserName': 'chrome',
'version': '',
'platform': 'ANY',
'chromeOptions': {
'args': [ '--headless', '--disable-gpu' ]
}
},
// -----------------------------------------------------------------
// Browser and Capabilities: Firefox
// -----------------------------------------------------------------
// capabilities: {
// 'browserName': 'firefox',
// 'version': '',
// 'platform': 'ANY'
// },
// -----------------------------------------------------------------
// Browser and Capabilities: MultiCapabilities
// -----------------------------------------------------------------
//multiCapabilities: [
// {
// 'browserName': 'phantomjs',
// 'version': '',
// 'platform': 'ANY'
// },
// {
// 'browserName': 'chrome',
// 'version': '',
// 'platform': 'ANY'
// }
// {
// 'browserName': 'firefox',
// 'version': '',
// 'platform': 'ANY'
// }
//],
plugins: [{
path: '../node_modules/protractor-istanbul-plugin'
}],
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 600000,
print: function () {}
},
useAllAngular2AppRoots: true,
beforeLaunch: function () {
require('ts-node').register({
project: './e2e/tsconfig.json'
});
},
onPrepare: function () {
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: 'pretty'
}
}));
}
};

View File

@@ -1,22 +0,0 @@
import { ProtractorPage } from './app.po';
describe('protractor App', () => {
let page: ProtractorPage;
beforeEach(() => {
page = new ProtractorPage();
});
it('should display translated title "DSpace Angular :: Home"', () => {
page.navigateTo();
page.waitUntilNotLoading();
expect<any>(page.getPageTitleText()).toEqual('DSpace Angular :: Home');
});
it('should contain a news section', () => {
page.navigateTo();
page.waitUntilNotLoading();
const text = page.getHomePageNewsText();
expect<any>(text).toBeDefined();
});
});

View File

@@ -1,23 +0,0 @@
import { browser, element, by, protractor, promise } from 'protractor';
export class ProtractorPage {
navigateTo() {
return browser.get('/')
.then(() => browser.waitForAngular());
}
getPageTitleText() {
return browser.getTitle();
}
getHomePageNewsText() {
return element(by.css('ds-home-news')).getText();
}
waitUntilNotLoading(): promise.Promise<unknown> {
const loading = element(by.css('.loader'));
const EC = protractor.ExpectedConditions;
const notLoading = EC.not(EC.presenceOf(loading));
return browser.wait(notLoading, 10000);
}
}

View File

@@ -1,36 +0,0 @@
import { ProtractorPage } from './item-statistics.po';
import { browser } from 'protractor';
import { UIURLCombiner } from '../../../src/app/core/url-combiner/ui-url-combiner';
describe('protractor Item statics', () => {
let page: ProtractorPage;
beforeEach(() => {
page = new ProtractorPage();
});
it('should contain element ds-item-page when navigating when navigating to an item page', () => {
page.navigateToItemPage();
expect<any>(page.elementTagExists('ds-item-page')).toEqual(true);
expect<any>(page.elementTagExists('ds-item-statistics-page')).toEqual(false);
});
it('should redirect to the entity page when navigating to an item page', () => {
page.navigateToItemPage();
expect(browser.getCurrentUrl()).toEqual(new UIURLCombiner(page.ENTITYPAGE).toString());
expect(browser.getCurrentUrl()).not.toEqual(new UIURLCombiner(page.ITEMSTATISTICSPAGE).toString());
expect(browser.getCurrentUrl()).not.toEqual(new UIURLCombiner(page.ITEMPAGE).toString());
});
it('should contain element ds-item-statistics-page when navigating when navigating to an item statistics page', () => {
page.navigateToItemStatisticsPage();
expect<any>(page.elementTagExists('ds-item-statistics-page')).toEqual(true);
expect<any>(page.elementTagExists('ds-item-page')).toEqual(false);
});
it('should contain the item statistics page url when navigating to an item statistics page', () => {
page.navigateToItemStatisticsPage();
expect(browser.getCurrentUrl()).toEqual(new UIURLCombiner(page.ITEMSTATISTICSPAGE).toString());
expect(browser.getCurrentUrl()).not.toEqual(new UIURLCombiner(page.ENTITYPAGE).toString());
expect(browser.getCurrentUrl()).not.toEqual(new UIURLCombiner(page.ITEMPAGE).toString());
});
});

View File

@@ -1,18 +0,0 @@
import { browser, element, by } from 'protractor';
export class ProtractorPage {
ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067';
ITEMSTATISTICSPAGE = '/statistics/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
navigateToItemPage() {
return browser.get(this.ITEMPAGE);
}
navigateToItemStatisticsPage() {
return browser.get(this.ITEMSTATISTICSPAGE);
}
elementTagExists(tag: string) {
return element(by.tagName(tag)).isPresent();
}
}

View File

@@ -1,19 +0,0 @@
import { ProtractorPage } from './pagenotfound.po';
describe('protractor PageNotFound', () => {
let page: ProtractorPage;
beforeEach(() => {
page = new ProtractorPage();
});
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
page.navigateToNonExistingPage();
expect<any>(page.elementTagExists('ds-pagenotfound')).toEqual(true);
});
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
page.navigateToExistingPage();
expect<any>(page.elementTagExists('ds-pagenotfound')).toEqual(false);
});
});

View File

@@ -1,18 +0,0 @@
import { browser, element, by } from 'protractor';
export class ProtractorPage {
HOMEPAGE = '/home';
NONEXISTINGPAGE = '/e9019a69-d4f1-4773-b6a3-bd362caa46f2';
navigateToNonExistingPage() {
return browser.get(this.NONEXISTINGPAGE);
}
navigateToExistingPage() {
return browser.get(this.HOMEPAGE);
}
elementTagExists(tag: string) {
return element(by.tagName(tag)).isPresent();
}
}

View File

@@ -1,46 +0,0 @@
import { ProtractorPage } from './search-navbar.po';
import { browser } from 'protractor';
describe('protractor SearchNavbar', () => {
let page: ProtractorPage;
let queryString: string;
beforeEach(() => {
page = new ProtractorPage();
queryString = 'the test query';
});
it('should go to search page with correct query if submitted (from home)', () => {
page.navigateToHome();
return checkIfSearchWorks();
});
it('should go to search page with correct query if submitted (from search)', () => {
page.navigateToSearch();
return checkIfSearchWorks();
});
it('check if can submit search box with pressing button', () => {
page.navigateToHome();
page.expandAndFocusSearchBox();
page.setCurrentQuery(queryString);
page.submitNavbarSearchForm();
browser.wait(() => {
return browser.getCurrentUrl().then((url: string) => {
return url.indexOf('query=' + encodeURI(queryString)) !== -1;
});
});
});
function checkIfSearchWorks(): boolean {
page.setCurrentQuery(queryString);
page.submitByPressingEnter();
browser.wait(() => {
return browser.getCurrentUrl().then((url: string) => {
return url.indexOf('query=' + encodeURI(queryString)) !== -1;
});
});
return false;
}
});

View File

@@ -1,35 +0,0 @@
import { browser, by, element, protractor } from 'protractor';
import { promise } from 'selenium-webdriver';
export class ProtractorPage {
HOME = '/home';
SEARCH = '/search';
navigateToHome() {
return browser.get(this.HOME);
}
navigateToSearch() {
return browser.get(this.SEARCH);
}
getCurrentQuery(): promise.Promise<string> {
return element(by.css('.navbar-container #search-navbar-container form input')).getAttribute('value');
}
expandAndFocusSearchBox() {
element(by.css('.navbar-container #search-navbar-container form a')).click();
}
setCurrentQuery(query: string) {
element(by.css('.navbar-container #search-navbar-container form input[name="query"]')).sendKeys(query);
}
submitNavbarSearchForm() {
element(by.css('.navbar-container #search-navbar-container form .submit-icon')).click();
}
submitByPressingEnter() {
element(by.css('.navbar-container #search-navbar-container form input[name="query"]')).sendKeys(protractor.Key.ENTER);
}
}

View File

@@ -1,60 +0,0 @@
import { ProtractorPage } from './search-page.po';
import { browser } from 'protractor';
describe('protractor SearchPage', () => {
let page: ProtractorPage;
beforeEach(() => {
page = new ProtractorPage();
});
it('should contain query value when navigating to page with query parameter', () => {
const queryString = 'Interesting query string';
page.navigateToSearchWithQueryParameter(queryString)
.then(() => page.getCurrentQuery())
.then((query: string) => {
expect<string>(query).toEqual(queryString);
});
});
it('should have right scope selected when navigating to page with scope parameter', () => {
page.navigateToSearch()
.then(() => page.getRandomScopeOption())
.then((scopeString: string) => {
page.navigateToSearchWithScopeParameter(scopeString);
page.waitUntilNotLoading();
page.getCurrentScope()
.then((s: string) => {
expect<string>(s).toEqual(scopeString);
});
});
});
it('should redirect to the correct url when scope was set and submit button was triggered', () => {
page.navigateToSearch()
.then(() => page.getRandomScopeOption())
.then((scopeString: string) => {
page.setCurrentScope(scopeString)
.then(() => page.submitSearchForm())
.then(() => page.waitUntilNotLoading())
.then(() => () => {
browser.wait(() => {
return browser.getCurrentUrl().then((url: string) => {
return url.indexOf('scope=' + encodeURI(scopeString)) !== -1;
});
});
});
});
});
it('should redirect to the correct url when query was set and submit button was triggered', () => {
const queryString = 'Another interesting query string';
page.setCurrentQuery(queryString);
page.submitSearchForm();
browser.wait(() => {
return browser.getCurrentUrl().then((url: string) => {
return url.indexOf('query=' + encodeURI(queryString)) !== -1;
});
});
});
});

View File

@@ -1,55 +0,0 @@
import { browser, by, element, protractor } from 'protractor';
import { promise } from 'selenium-webdriver';
export class ProtractorPage {
SEARCH = '/search';
navigateToSearch() {
return browser.get(this.SEARCH);
}
navigateToSearchWithQueryParameter(query: string) {
return browser.get(this.SEARCH + '?query=' + query);
}
navigateToSearchWithScopeParameter(scope: string) {
return browser.get(this.SEARCH + '?scope=' + scope);
}
getCurrentScope(): promise.Promise<string> {
const scopeSelect = element(by.css('#search-form select'));
browser.wait(protractor.ExpectedConditions.presenceOf(scopeSelect), 10000);
return scopeSelect.getAttribute('value');
}
getCurrentQuery(): promise.Promise<string> {
return element(by.css('#search-form input')).getAttribute('value');
}
setCurrentScope(scope: string) {
return element(by.css('#search-form option[value="' + scope + '"]')).click();
}
setCurrentQuery(query: string) {
element(by.css('#search-form input[name="query"]')).sendKeys(query);
}
submitSearchForm() {
return element(by.css('#search-form button.search-button')).click();
}
getRandomScopeOption(): promise.Promise<string> {
const options = element(by.css('select[name="scope"]')).all(by.tagName('option'));
return options.count().then((c: number) => {
const index: number = Math.floor(Math.random() * (c - 1));
return options.get(index + 1).getAttribute('value');
});
}
waitUntilNotLoading(): promise.Promise<unknown> {
const loading = element(by.css('.loader'));
const EC = protractor.ExpectedConditions;
const notLoading = EC.not(EC.presenceOf(loading));
return browser.wait(notLoading, 10000);
}
}

View File

@@ -1,16 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../dist/out-tsc-e2e",
"sourceMap": true,
"target": "es2018",
"typeRoots": [
"../node_modules/@types"
]
}
}

View File

@@ -22,7 +22,7 @@ module.exports = function (config) {
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['mocha', 'kjhtml'],
reporters: ['mocha', 'kjhtml', 'coverage-istanbul'],
mochaReporter: {
ignoreSkipped: true,
output: 'autowatch'

View File

@@ -1,5 +0,0 @@
{
"watch": ["src/environments/mock-environment.ts"],
"ext": "ts",
"exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts"
}

View File

@@ -1,6 +1,6 @@
{
"watch": ["src/environments"],
"ext": "ts",
"ignore": ["src/environments/environment.ts", "src/environments/mock-environment.ts"],
"exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev"
"watch": [
"config"
],
"ext": "json"
}

View File

@@ -3,50 +3,41 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"config:dev": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev",
"config:prod": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --prod",
"config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts",
"config:test:watch": "nodemon --config mock-nodemon.json",
"config:dev:watch": "nodemon",
"prestart:dev": "yarn run config:dev",
"prebuild": "yarn run config:dev",
"pretest": "yarn run config:test",
"pretest:watch": "yarn run config:test",
"pretest:headless": "yarn run config:test",
"prebuild:prod": "yarn run config:prod",
"pree2e": "yarn run config:prod",
"pree2e:ci": "yarn run config:prod",
"config:watch": "nodemon",
"test:rest": "ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
"start": "yarn run start:prod",
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"start:dev": "npm-run-all --parallel config:dev:watch serve",
"start:prod": "yarn run build:prod && yarn run serve:ssr",
"start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"",
"start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"serve": "ng serve -c development",
"serve:ssr": "node dist/server/main",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build",
"build": "ng build -c development",
"build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr",
"build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server",
"build:client-and-server-bundles": "ng build --prod && ng run dspace-angular:server:production --bundleDependencies true",
"test:watch": "npm-run-all --parallel config:test:watch test",
"test": "ng test --sourceMap=true --watch=true",
"test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"test": "ng test --sourceMap=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"",
"test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint",
"lint-fix": "ng lint --fix=true",
"e2e": "ng e2e",
"e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js",
"compile:server": "webpack --config webpack.server.config.js --progress --color",
"serve:ssr": "node dist/server",
"clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage",
"clean:dist": "rimraf dist",
"clean:doc": "rimraf doc",
"clean:log": "rimraf *.log*",
"clean:json": "rimraf *.records.json",
"clean:bld": "rimraf build",
"clean:node": "rimraf node_modules",
"clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld",
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
"clean:env": "rimraf src/environments/environment.ts",
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"postinstall": "ngcc"
"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",
"sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts",
"check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./"
},
"browser": {
"fs": false,
@@ -57,40 +48,43 @@
"private": true,
"resolutions": {
"minimist": "^1.2.5",
"webdriver-manager": "^12.1.8"
"webdriver-manager": "^12.1.8",
"ts-node": "10.2.1"
},
"dependencies": {
"@angular/animations": "~10.2.3",
"@angular/cdk": "^10.2.6",
"@angular/common": "~10.2.3",
"@angular/compiler": "~10.2.3",
"@angular/core": "~10.2.3",
"@angular/forms": "~10.2.3",
"@angular/localize": "10.2.3",
"@angular/platform-browser": "~10.2.3",
"@angular/platform-browser-dynamic": "~10.2.3",
"@angular/platform-server": "~10.2.3",
"@angular/router": "~10.2.3",
"@angularclass/bootloader": "1.0.1",
"@ng-bootstrap/ng-bootstrap": "7.0.0",
"@ng-dynamic-forms/core": "^12.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0",
"@ngrx/effects": "^10.0.1",
"@ngrx/router-store": "^10.0.1",
"@ngrx/store": "^10.0.1",
"@nguniversal/express-engine": "10.1.0",
"@angular/animations": "~13.2.6",
"@angular/cdk": "^13.2.6",
"@angular/common": "~13.2.6",
"@angular/compiler": "~13.2.6",
"@angular/core": "~13.2.6",
"@angular/forms": "~13.2.6",
"@angular/localize": "13.2.6",
"@angular/platform-browser": "~13.2.6",
"@angular/platform-browser-dynamic": "~13.2.6",
"@angular/platform-server": "~13.2.6",
"@angular/router": "~13.2.6",
"@babel/runtime": "^7.17.2",
"@kolkov/ngx-gallery": "^2.0.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^15.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
"@ngrx/effects": "^13.0.2",
"@ngrx/router-store": "^13.0.2",
"@ngrx/store": "^13.0.2",
"@nguniversal/express-engine": "^13.0.2",
"@ngx-translate/core": "^13.0.0",
"@nicky-lenaers/ngx-scroll-to": "^9.0.0",
"angular-idle-preload": "3.0.0",
"angular2-text-mask": "9.0.0",
"angulartics2": "^10.0.0",
"angulartics2": "^12.0.0",
"bootstrap": "4.3.1",
"caniuse-lite": "^1.0.30001165",
"cerialize": "0.1.18",
"cli-progress": "^3.8.0",
"compression": "^1.7.4",
"cookie-parser": "1.4.5",
"core-js": "^3.7.0",
"debug-loader": "^0.0.1",
"deepmerge": "^4.2.2",
"express": "^4.17.1",
"express-rate-limit": "^5.1.3",
@@ -98,93 +92,122 @@
"file-saver": "^2.0.5",
"filesize": "^6.1.0",
"font-awesome": "4.7.0",
"http-proxy-middleware": "^1.0.5",
"https": "1.0.0",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.1.3",
"jsonschema": "1.4.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.10",
"lodash": "^4.17.21",
"mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
"moment": "^2.29.1",
"morgan": "^1.10.0",
"ng-mocks": "10.5.4",
"ng-mocks": "^13.1.1",
"ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.2",
"ng2-nouislider": "^1.8.3",
"ngx-infinite-scroll": "^10.0.1",
"ngx-moment": "^5.0.0",
"ngx-pagination": "5.0.0",
"ngx-sortablejs": "^10.0.0",
"ngx-sortablejs": "^11.1.0",
"nouislider": "^14.6.3",
"pem": "1.14.4",
"postcss-cli": "^8.3.0",
"postcss-cli": "^9.1.0",
"prop-types": "^15.7.2",
"react-copy-to-clipboard": "^5.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^6.6.3",
"rxjs-spy": "^7.5.3",
"sass-resources-loader": "^2.1.1",
"rxjs": "^7.5.5",
"sortablejs": "1.13.0",
"tslib": "^2.0.0",
"url-parse": "^1.5.6",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "^0.10.3",
"@kolkov/ngx-gallery": "^1.2.3"
"zone.js": "~0.11.5"
},
"devDependencies": {
"@angular-builders/custom-webpack": "10.0.1",
"@angular-devkit/build-angular": "~0.1002.0",
"@angular/cli": "~10.2.0",
"@angular/compiler-cli": "~10.2.3",
"@angular/language-service": "~10.2.3",
"@angular-builders/custom-webpack": "~13.1.0",
"@angular-devkit/build-angular": "~13.2.6",
"@angular-eslint/builder": "13.1.0",
"@angular-eslint/eslint-plugin": "13.1.0",
"@angular-eslint/eslint-plugin-template": "13.1.0",
"@angular-eslint/schematics": "13.1.0",
"@angular-eslint/template-parser": "13.1.0",
"@angular/cli": "~13.2.6",
"@angular/compiler-cli": "~13.2.6",
"@angular/language-service": "~13.2.6",
"@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^5.5.0",
"@ngrx/store-devtools": "^10.0.1",
"@ngtools/webpack": "10.2.0",
"@nguniversal/builders": "~10.1.0",
"@ngrx/store-devtools": "^13.0.2",
"@ngtools/webpack": "^13.2.6",
"@nguniversal/builders": "^13.0.2",
"@types/deep-freeze": "0.1.2",
"@types/express": "^4.17.9",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "^3.6.2",
"@types/jasmine": "~3.6.0",
"@types/jasminewd2": "~2.0.8",
"@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.165",
"@types/node": "^14.14.9",
"codelyzer": "^6.0.1",
"compression-webpack-plugin": "^3.0.1",
"@typescript-eslint/eslint-plugin": "5.11.0",
"@typescript-eslint/parser": "5.11.0",
"axe-core": "^4.3.3",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"css-loader": "3.4.0",
"cssnano": "^4.1.10",
"cross-env": "^7.0.3",
"css-loader": "^6.2.0",
"css-minimizer-webpack-plugin": "^3.4.1",
"cssnano": "^5.0.6",
"cypress": "9.5.1",
"cypress-axe": "^0.14.0",
"debug-loader": "^0.0.1",
"deep-freeze": "0.0.1",
"dotenv": "^8.2.0",
"eslint": "^8.2.0",
"eslint-plugin-deprecation": "^1.3.2",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^38.0.6",
"eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.5",
"fork-ts-checker-webpack-plugin": "^6.0.3",
"html-webpack-plugin": "^4.5.0",
"http-proxy-middleware": "^1.0.5",
"jasmine-core": "^3.6.0",
"jasmine-marbles": "0.6.0",
"jasmine-spec-reporter": "^6.0.0",
"karma": "^5.2.3",
"karma-chrome-launcher": "^3.1.0",
"html-loader": "^1.3.2",
"jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
"jasmine-spec-reporter": "~5.0.0",
"karma": "^6.3.14",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "^4.0.1",
"karma-jasmine-html-reporter": "^1.5.4",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5",
"nodemon": "^2.0.2",
"npm-run-all": "^4.1.5",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-apply": "0.11.0",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "6.7.0",
"ngx-mask": "^13.1.7",
"nodemon": "^2.0.15",
"postcss": "^8.1",
"postcss-apply": "0.12.0",
"postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2",
"postcss-responsive-type": "1.0.0",
"protractor": "^7.0.0",
"protractor-istanbul-plugin": "2.0.0",
"raw-loader": "0.5.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"rimraf": "^3.0.2",
"script-ext-html-webpack-plugin": "2.1.5",
"string-replace-loader": "^2.3.0",
"rxjs-spy": "^8.0.2",
"sass": "~1.32.6",
"sass-loader": "^12.6.0",
"sass-resources-loader": "^2.1.1",
"string-replace-loader": "^3.1.0",
"terser-webpack-plugin": "^2.3.1",
"ts-loader": "^5.2.0",
"ts-node": "^8.8.1",
"tslint": "^6.1.3",
"typescript": "~4.0.5",
"webpack": "^4.44.2",
"ts-node": "^8.10.2",
"typescript": "~4.5.5",
"webpack": "^5.69.1",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^4.2.0",
"webpack-node-externals": "1.7.2"
"webpack-dev-server": "^4.5.0"
}
}

39
scripts/env-to-yaml.ts Normal file
View File

@@ -0,0 +1,39 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { join } from 'path';
/**
* Script to help convert previous version environment.*.ts to yaml.
*
* Usage (see package.json):
*
* yarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file) *
*/
const args = process.argv.slice(2);
if (args[0] === undefined) {
console.log(`Usage:\n\tyarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file)\n`);
process.exit(0);
}
const envFullPath = join(process.cwd(), args[0]);
if (!fs.existsSync(envFullPath)) {
console.error(`Error:\n${envFullPath} does not exist\n`);
process.exit(1);
}
try {
const env = require(envFullPath).environment;
const config = yaml.dump(env);
if (args[1]) {
const ymlFullPath = join(process.cwd(), args[1]);
fs.writeFileSync(ymlFullPath, config);
} else {
console.log(config);
}
} catch (e) {
console.error(e);
}

View File

@@ -0,0 +1,99 @@
import { projectRoot} from '../webpack/helpers';
const commander = require('commander');
const fs = require('fs');
const JSON5 = require('json5');
const _cliProgress = require('cli-progress');
const _ = require('lodash');
const program = new commander.Command();
program.version('1.0.0', '-v, --version');
const LANGUAGE_FILES_LOCATION = 'src/assets/i18n';
parseCliInput();
/**
* Purpose: Allows customization of i18n labels from within themes
* e.g. Customize the label "menu.section.browse_global" to display "Browse DSpace" rather than "All of DSpace"
*
* This script uses the i18n files found in a source directory to override settings in files with the same
* name in a destination directory. Only the i18n labels to be overridden need be in the source files.
*
* Execution (using custom theme):
* ```
* yarn merge-i18n -s src/themes/custom/assets/i18n
* ```
*
* Input parameters:
* * Output directory: The directory in which the original i18n files are stored
* - Defaults to src/assets/i18n (the default i18n file location)
* - This is where the final output files will be written
* * Source directory: The directory with override files
* - Required
* - Recommended to place override files in the theme directory under assets/i18n (but this is not required)
* - Files must have matching names in both source and destination directories, for example:
* en.json5 in the source directory will be merged with en.json5 in the destination directory
* fr.json5 in the source directory will be merged with fr.json5 in the destination directory
*/
function parseCliInput() {
program
.option('-d, --output-dir <output-dir>', 'output dir when running script on all language files', projectRoot(LANGUAGE_FILES_LOCATION))
.option('-s, --source-dir <source-dir>', 'source dir of transalations to be merged')
.usage('(-s <source-dir> [-d <output-dir>])')
.parse(process.argv);
if (program.outputDir && program.sourceDir) {
if (!fs.existsSync(program.outputDir) && !fs.lstatSync(program.outputDir).isDirectory() ) {
console.error('Output does not exist or is not a directory.');
console.log(program.outputHelp());
process.exit(1);
}
if (!fs.existsSync(program.sourceDir) && !fs.lstatSync(program.sourceDir).isDirectory() ) {
console.error('Source does not exist or is not a directory.');
console.log(program.outputHelp());
process.exit(1);
}
fs.readdirSync(projectRoot(program.sourceDir)).forEach(file => {
if (fs.existsSync(program.outputDir + '/' + file) ) {
console.log('Merging: ' + program.outputDir + '/' + file + ' with ' + program.sourceDir + '/' + file);
mergeFileWithSource(program.sourceDir + '/' + file, program.outputDir + '/' + file);
}
});
} else {
console.error('Source or Output parameter is missing.');
console.log(program.outputHelp());
process.exit(1);
}
}
/**
* Reads source file and output file to merge the contents
* > Iterates over the source file keys
* > Updates values for each key and adds new keys as needed
* > Updates the output file with the new merged json
* @param pathToSourceFile Valid path to source file to merge from
* @param pathToOutputFile Valid path to merge and write output
*/
function mergeFileWithSource(pathToSourceFile, pathToOutputFile) {
const progressBar = new _cliProgress.SingleBar({}, _cliProgress.Presets.shades_classic);
progressBar.start(100, 0);
const sourceFile = fs.readFileSync(pathToSourceFile, 'utf8');
progressBar.update(10);
const outputFile = fs.readFileSync(pathToOutputFile, 'utf8');
progressBar.update(20);
const parsedSource = JSON5.parse(sourceFile);
progressBar.update(30);
const parsedOutput = JSON5.parse(outputFile);
progressBar.update(40);
for (const key of Object.keys(parsedSource)) {
parsedOutput[key] = parsedSource[key];
}
progressBar.update(80);
fs.writeFileSync(pathToOutputFile,JSON5.stringify(parsedOutput,{ space:'\n ', quote: '"' }), { encoding:'utf8' });
progressBar.update(100);
progressBar.stop();
}

View File

@@ -1,11 +1,14 @@
import { environment } from '../src/environments/environment';
import * as child from 'child_process';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
const appConfig: AppConfig = buildAppConfig();
/**
* Calls `ng serve` with the following arguments configured for the UI in the environment file: host, port, nameSpace, ssl
* Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl
*/
child.spawn(
`ng serve --host ${environment.ui.host} --port ${environment.ui.port} --servePath ${environment.ui.nameSpace} --ssl ${environment.ui.ssl}`,
`ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`,
{ stdio: 'inherit', shell: true }
);

View File

@@ -1,116 +0,0 @@
import { writeFile } from 'fs';
import { environment as commonEnv } from '../src/environments/environment.common';
import { GlobalConfig } from '../src/config/global-config.interface';
import { ServerConfig } from '../src/config/server-config.interface';
import { hasValue } from '../src/app/shared/empty.util';
// Configure Angular `environment.ts` file path
const targetPath = './src/environments/environment.ts';
// Load node modules
const colors = require('colors');
require('dotenv').config();
const merge = require('deepmerge');
const mergeOptions = { arrayMerge: (destinationArray, sourceArray, options) => sourceArray };
const environment = process.argv[2];
let environmentFilePath;
let production = false;
switch (environment) {
case '--prod':
case '--production':
production = true;
console.log(`Building ${colors.red.bold(`production`)} environment`);
environmentFilePath = '../src/environments/environment.prod.ts';
break;
case '--test':
console.log(`Building ${colors.blue.bold(`test`)} environment`);
environmentFilePath = '../src/environments/environment.test.ts';
break;
default:
console.log(`Building ${colors.green.bold(`development`)} environment`);
environmentFilePath = '../src/environments/environment.dev.ts';
}
const processEnv = {
ui: createServerConfig(
process.env.DSPACE_HOST,
process.env.DSPACE_PORT,
process.env.DSPACE_NAMESPACE,
process.env.DSPACE_SSL),
rest: createServerConfig(
process.env.DSPACE_REST_HOST,
process.env.DSPACE_REST_PORT,
process.env.DSPACE_REST_NAMESPACE,
process.env.DSPACE_REST_SSL)
} as GlobalConfig;
import(environmentFilePath)
.then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv], mergeOptions)))
.catch(() => {
console.log(colors.yellow.bold(`No specific environment file found for ` + environment));
generateEnvironmentFile(merge(commonEnv, processEnv, mergeOptions))
});
function generateEnvironmentFile(file: GlobalConfig): void {
file.production = production;
buildBaseUrls(file);
const contents = `export const environment = ` + JSON.stringify(file);
writeFile(targetPath, contents, (err) => {
if (err) {
throw console.error(err);
} else {
console.log(`Angular ${colors.bold('environment.ts')} file generated correctly at ${colors.bold(targetPath)} \n`);
}
});
}
// allow to override a few important options by environment variables
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
const result = {} as any;
if (hasValue(host)) {
result.host = host;
}
if (hasValue(nameSpace)) {
result.nameSpace = nameSpace;
}
if (hasValue(port)) {
result.port = Number(port);
}
if (hasValue(ssl)) {
result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false;
}
return result;
}
function buildBaseUrls(config: GlobalConfig): void {
for (const key in config) {
if (config.hasOwnProperty(key) && config[key].host) {
config[key].baseUrl = [
getProtocol(config[key].ssl),
getHost(config[key].host),
getPort(config[key].port),
getNameSpace(config[key].nameSpace)
].join('');
}
}
}
function getProtocol(ssl: boolean): string {
return ssl ? 'https://' : 'http://';
}
function getHost(host: string): string {
return host;
}
function getPort(port: number): string {
return port ? (port !== 80 && port !== 443) ? ':' + port : '' : '';
}
function getNameSpace(nameSpace: string): string {
return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : '';
}

View File

@@ -1,11 +0,0 @@
import { copyFile } from 'fs';
// Configure Angular `environment.ts` file path
const sourcePath = './src/environments/mock-environment.ts';
const targetPath = './src/environments/environment.ts';
// destination.txt will be created or overwritten by default.
copyFile(sourcePath, targetPath, (err) => {
if (err) throw err;
console.log(sourcePath + ' was copied to ' + targetPath);
});

70
scripts/test-rest.ts Normal file
View File

@@ -0,0 +1,70 @@
import * as http from 'http';
import * as https from 'https';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
const appConfig: AppConfig = buildAppConfig();
/**
* Script to test the connection with the configured REST API (in the 'rest' settings of your config.*.yaml)
*
* This script is useful to test for any Node.js connection issues with your REST API.
*
* Usage (see package.json): yarn test:rest
*/
// Get root URL of configured REST API
const restUrl = appConfig.rest.baseUrl + '/api';
console.log(`...Testing connection to REST API at ${restUrl}...\n`);
// If SSL enabled, test via HTTPS, else via HTTP
if (appConfig.rest.ssl) {
const req = https.request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
res.on('data', (data) => {
checkJSONResponse(data);
});
});
req.on('error', error => {
console.error('ERROR connecting to REST API\n' + error);
});
req.end();
} else {
const req = http.request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
res.on('data', (data) => {
checkJSONResponse(data);
});
});
req.on('error', error => {
console.error('ERROR connecting to REST API\n' + error);
});
req.end();
}
/**
* Check JSON response from REST API to see if it looks valid. Log useful information
* @param responseData response data
*/
function checkJSONResponse(responseData: any): any {
let parsedData;
try {
parsedData = JSON.parse(responseData);
console.log('Checking JSON returned for validity...');
console.log(`\t"dspaceVersion" = ${parsedData.dspaceVersion}`);
console.log(`\t"dspaceUI" = ${parsedData.dspaceUI}`);
console.log(`\t"dspaceServer" = ${parsedData.dspaceServer}`);
console.log(`\t"dspaceServer" property matches UI's "rest" config? ${(parsedData.dspaceServer === appConfig.rest.baseUrl)}`);
// Check for "authn" and "sites" in "_links" section as they should always exist (even if no data)!
const linksFound: string[] = Object.keys(parsedData._links);
console.log(`\tDoes "/api" endpoint have HAL links ("_links" section)? ${linksFound.includes('authn') && linksFound.includes('sites')}`);
} catch (err) {
console.error('ERROR: INVALID DSPACE REST API! Response is not valid JSON!');
console.error(`Response returned:\n${responseData}`);
}
}

View File

@@ -15,40 +15,55 @@
* import for `ngExpressEngine`.
*/
import 'zone.js/dist/zone-node';
import 'zone.js/node';
import 'reflect-metadata';
import 'rxjs';
import * as fs from 'fs';
import * as pem from 'pem';
import * as https from 'https';
import * as morgan from 'morgan';
import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core';
import { existsSync } from 'fs';
import { ngExpressEngine } from '@nguniversal/express-engine';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { hasValue, hasNoValue } from './src/app/shared/empty.util';
import { APP_BASE_HREF } from '@angular/common';
import { UIServerConfig } from './src/config/ui-server-config.interface';
import { ServerAppModule } from './src/main.server';
import { buildAppConfig } from './src/config/config.server';
import { AppConfig, APP_CONFIG } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
/*
* Set path for the browser application's dist folder
*/
const DIST_FOLDER = join(process.cwd(), 'dist/browser');
// Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const { ServerAppModule, ngExpressEngine } = require('./dist/server/main');
const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
@@ -57,15 +72,18 @@ export function app() {
*/
const server = express();
/*
* If production mode is enabled in the environment file:
* - Enable Angular's production mode
* - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression)
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/
if (environment.production) {
enableProdMode();
server.use(compression());
server.use(compression({
// only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
filter: (_, res) => res.locals.ssr,
}));
}
/*
@@ -99,7 +117,11 @@ export function app() {
provide: RESPONSE,
useValue: (options as any).req.res,
},
],
{
provide: APP_CONFIG,
useValue: environment
}
]
})(_, (options as any), callback)
);
@@ -133,8 +155,18 @@ export function app() {
/*
* Serve static resources (images, i18n messages, …)
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
*/
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
server.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
index: false,
enableBrotli: true,
orderPreference: ['br', 'gzip'],
}));
/*
* Fallthrough to the IIIF viewer (must be included in the build).
*/
server.use('/iiif', express.static(IIIF_VIEWER, {index:false}));
// Register the ngApp callback function to handle incoming requests
server.get('*', ngApp);
@@ -159,6 +191,7 @@ function ngApp(req, res) {
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
@@ -221,6 +254,7 @@ function run() {
});
}
function start() {
/*
* If SSL is enabled
* - Read credentials from configuration files
@@ -231,14 +265,14 @@ function run() {
if (environment.ui.ssl) {
let serviceKey;
try {
serviceKey = fs.readFileSync('./config/ssl/key.pem');
serviceKey = readFileSync('./config/ssl/key.pem');
} catch (e) {
console.warn('Service key not found at ./config/ssl/key.pem');
}
let certificate;
try {
certificate = fs.readFileSync('./config/ssl/cert.pem');
certificate = readFileSync('./config/ssl/cert.pem');
} catch (e) {
console.warn('Certificate not found at ./config/ssl/key.pem');
}
@@ -263,5 +297,16 @@ if (environment.ui.ssl) {
} else {
run();
}
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule && mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
start();
}
export * from './src/main.server';

View File

@@ -9,13 +9,15 @@ import { GroupFormComponent } from './group-registry/group-form/group-form.compo
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 { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
RouterModule,
AccessControlRoutingModule
AccessControlRoutingModule,
FormModule
],
declarations: [
EPeopleRegistryComponent,

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { type } from '../../shared/ngrx/type';
@@ -16,7 +17,6 @@ export const EPeopleRegistryActionTypes = {
CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_EDIT_EPERSON'),
};
/* tslint:disable:max-classes-per-file */
/**
* Used to edit an EPerson in the EPeople registry
*/
@@ -37,7 +37,6 @@ export class EPeopleRegistryCancelEPersonAction implements Action {
type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON;
}
/* tslint:enable:max-classes-per-file */
/**
* Export a type alias of all actions in this action group

View File

@@ -9,7 +9,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data';
import { FindListOptions } from '../../core/data/request.models';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { PageInfo } from '../../core/shared/page-info.model';
@@ -27,6 +26,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
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';
describe('EPeopleRegistryComponent', () => {
let component: EPeopleRegistryComponent;

View File

@@ -19,7 +19,7 @@
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)">
<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>
@@ -36,9 +36,13 @@
</button>
</ds-form>
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading>
<div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-loading>
<ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
@@ -52,15 +56,17 @@
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<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>{{group.id}}</td>
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
<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>

View File

@@ -2,18 +2,18 @@ import { Observable, of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { FindListOptions } from '../../../core/data/request.models';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { EPeopleRegistryComponent } from '../epeople-registry.component';
import { EPersonFormComponent } from './eperson-form.component';
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
@@ -28,6 +28,9 @@ 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 { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
describe('EPersonFormComponent', () => {
let component: EPersonFormComponent;
@@ -39,6 +42,7 @@ describe('EPersonFormComponent', () => {
let authService: AuthServiceStub;
let authorizationService: AuthorizationDataService;
let groupsDataService: GroupDataService;
let epersonRegistrationService: EpersonRegistrationService;
let paginationService;
@@ -99,12 +103,78 @@ describe('EPersonFormComponent', () => {
}
});
return createSuccessfulRemoteDataObject$(ePerson);
},
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
}
};
builderService = getMockFormBuilderService();
builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) {
const controls = {};
formModel.forEach( model => {
model.parent = parent;
const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions);
});
return new FormGroup(controls, options);
},
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return {
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
};
},
getValidators(validatorsConfig) {
return this.getValidatorFns(validatorsConfig);
},
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
let validatorFns = [];
if (this.isObject(validatorsConfig)) {
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
const validatorConfigValue = validatorsConfig[validatorConfigKey];
if (this.isValidatorDescriptor(validatorConfigValue)) {
const descriptor = validatorConfigValue;
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
}
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
});
}
return validatorFns;
},
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
let validatorFn;
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
validatorFn = Validators[validatorName];
} else { // Custom Validators
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
} else if (validatorsToken) {
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
}
}
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`);
}
if (validatorArgs !== null) {
return validatorFn(validatorArgs);
}
return validatorFn;
},
isValidatorDescriptor(value) {
if (this.isObject(value)) {
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
}
return false;
},
isObject(value) {
return typeof value === 'object' && value !== null;
}
});
authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
isAuthorized: observableOf(true),
});
groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
@@ -130,12 +200,18 @@ describe('EPersonFormComponent', () => {
{ provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: PaginationService, useValue: paginationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])},
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
EPeopleRegistryComponent
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
registerEmail: createSuccessfulRemoteDataObject$(null)
});
beforeEach(() => {
fixture = TestBed.createComponent(EPersonFormComponent);
component = fixture.componentInstance;
@@ -146,6 +222,131 @@ describe('EPersonFormComponent', () => {
expect(component).toBeDefined();
});
describe('check form validation', () => {
let firstName;
let lastName;
let email;
let canLogIn;
let requireCertificate;
let expected;
beforeEach(() => {
firstName = 'testName';
lastName = 'testLastName';
email = 'testEmail@test.com';
canLogIn = false;
requireCertificate = false;
expected = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: firstName
}
],
'eperson.lastname': [
{
value: lastName
},
],
},
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit');
component.canLogIn.value = canLogIn;
component.requireCertificate.value = requireCertificate;
fixture.detectChanges();
component.initialisePage();
fixture.detectChanges();
});
describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the lastName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeFalse();
expect(component.formGroup.controls.lastName.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', () => {
beforeEach(() => {
component.formGroup.controls.firstName.setValue('test');
component.formGroup.controls.lastName.setValue('test');
component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges();
});
it('firstName should be valid because the firstName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull();
});
}));
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.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();
});
}));
});
describe('after inserting email wrong should show pattern validation error', () => {
beforeEach(() => {
component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges();
});
it('email should not be valid because the email pattern', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
});
}));
});
describe('after already utilized email', () => {
beforeEach(() => {
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(EPersonMock);
}
});
component.formGroup.controls.email.setValue('test@test.com');
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
fixture.detectChanges();
});
it('email should not be valid because email is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
});
}));
});
});
describe('when submitting the form', () => {
let firstName;
let lastName;
@@ -320,4 +521,23 @@ describe('EPersonFormComponent', () => {
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);
});
});
describe('Reset Password', () => {
let ePersonId;
let ePersonEmail;
beforeEach(() => {
ePersonId = 'testEPersonId';
ePersonEmail = 'person.email@4science.it';
component.epersonInitial = Object.assign(new EPerson(), {
id: ePersonId,
email: ePersonEmail
});
component.resetPassword();
});
it('should call epersonRegistrationService.registerEmail', () => {
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail);
});
});
});

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
DynamicCheckboxModel,
@@ -8,7 +8,7 @@ import {
} from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
import { debounceTime, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
@@ -32,10 +32,14 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { Registration } from '../../../core/shared/registration.model';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
@Component({
selector: 'ds-eperson-form',
templateUrl: './eperson-form.component.html'
templateUrl: './eperson-form.component.html',
})
/**
* A form used for creating and editing EPeople
@@ -119,7 +123,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Observable whether or not the admin is allowed to reset the EPerson's password
* TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false)
*/
canReset$: Observable<boolean> = observableOf(false);
canReset$: Observable<boolean>;
/**
* Observable whether or not the admin is allowed to delete the EPerson
@@ -160,7 +164,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/
isImpersonated = false;
constructor(public epersonService: EPersonDataService,
/**
* Subscription to email field value change
*/
emailValueChangeSubscribe: Subscription;
constructor(
protected changeDetectorRef: ChangeDetectorRef,
public epersonService: EPersonDataService,
public groupsDataService: GroupDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
@@ -169,7 +180,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
private paginationService: PaginationService,
public requestService: RequestService) {
public requestService: RequestService,
private epersonRegistrationService: EpersonRegistrationService,
) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
if (hasValue(eperson)) {
@@ -186,6 +199,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page
*/
initialisePage() {
observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -218,9 +232,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
name: 'email',
validators: {
required: null,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
},
required: true,
errorMessages: {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail'
},
hint: emailHint
});
this.canLogIn = new DynamicCheckboxModel(
@@ -259,6 +277,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false
});
if (eperson === null && !!this.formGroup.controls.email) {
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
}));
const activeEPerson$ = this.epersonService.getActiveEPerson();
@@ -272,18 +297,25 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}),
switchMap(([eperson, findListOptions]) => {
if (eperson != null) {
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions);
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
}
return observableOf(undefined);
})
);
this.canImpersonate$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
} else {
return observableOf(false);
}
})
);
this.canDelete$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
);
this.canReset$ = observableOf(true);
});
}
@@ -394,28 +426,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
}
/**
* Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case
* @param ePerson ePerson values to check
* @param notificationSection whether in create or edit
*/
private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) {
// Relevant message for email in use
this.subs.push(this.epersonService.searchByScope('email', ePerson.email, {
currentPage: 1,
elementsPerPage: 0
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.subscribe((list: PaginatedList<EPerson>) => {
if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
name: ePerson.name,
email: ePerson.email
}));
}
}));
}
/**
* Event triggered when the user changes page
* @param event
@@ -427,15 +437,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
}
/**
* Update the list of groups by fetching it from the rest api or cache
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
}));
}
/**
* Start impersonating the EPerson
*/
@@ -470,7 +471,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
this.cancelForm.emit();
});
}}
}
}
});
});
}
@@ -483,6 +485,26 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.isImpersonated = false;
}
/**
* Sends an email to current eperson address with the information
* to reset password
*/
resetPassword() {
if (hasValue(this.epersonInitial.email)) {
this.epersonRegistrationService.registerEmail(this.epersonInitial.email).pipe(getFirstCompletedRemoteData())
.subscribe((response: RemoteData<Registration>) => {
if (response.hasSucceeded) {
this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'),
this.translateService.get('forgot-email.form.success.content', {email: this.epersonInitial.email}));
} else {
this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'),
this.translateService.get('forgot-email.form.error.content', {email: this.epersonInitial.email}));
}
}
);
}
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
@@ -490,8 +512,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id);
if (hasValue(this.emailValueChangeSubscribe)) {
this.emailValueChangeSubscribe.unsubscribe();
}
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
@@ -502,4 +526,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
this.initialisePage();
}
/**
* Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case
* @param ePerson ePerson values to check
* @param notificationSection whether in create or edit
*/
private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) {
// Relevant message for email in use
this.subs.push(this.epersonService.searchByScope('email', ePerson.email, {
currentPage: 1,
elementsPerPage: 0
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.subscribe((list: PaginatedList<EPerson>) => {
if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
name: ePerson.name,
email: ePerson.email
}));
}
}));
}
/**
* Update the list of groups by fetching it from the rest api or cache
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
}));
}
}

View File

@@ -0,0 +1,25 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { getFirstSucceededRemoteData, } from '../../../../core/shared/operators';
export class ValidateEmailNotTaken {
/**
* This method will create the validator with the ePersonDataService requested from component
* @param ePersonDataService the service with DI in the component that this validator is being utilized.
*/
static createValidator(ePersonDataService: EPersonDataService) {
return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
return ePersonDataService.getEPersonByEmail(control.value)
.pipe(
getFirstSucceededRemoteData(),
map(res => {
return !!res.payload ? { emailTaken: true } : null;
})
);
};
}
}

View File

@@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, 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 { Store } from '@ngrx/store';
@@ -34,6 +34,8 @@ import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mo
import { RouterMock } from '../../../shared/mocks/router.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
import { NoContent } from '../../../core/shared/NoContent.model';
describe('GroupFormComponent', () => {
let component: GroupFormComponent;
@@ -86,6 +88,9 @@ describe('GroupFormComponent', () => {
patch(group: Group, operations: Operation[]) {
return null;
},
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
cancelEditGroup(): void {
this.activeGroup = null;
},
@@ -117,7 +122,69 @@ describe('GroupFormComponent', () => {
return null;
}
};
builderService = getMockFormBuilderService();
builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) {
const controls = {};
formModel.forEach( model => {
model.parent = parent;
const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions);
});
return new FormGroup(controls, options);
},
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return {
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
};
},
getValidators(validatorsConfig) {
return this.getValidatorFns(validatorsConfig);
},
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
let validatorFns = [];
if (this.isObject(validatorsConfig)) {
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
const validatorConfigValue = validatorsConfig[validatorConfigKey];
if (this.isValidatorDescriptor(validatorConfigValue)) {
const descriptor = validatorConfigValue;
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
}
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
});
}
return validatorFns;
},
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
let validatorFn;
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
validatorFn = Validators[validatorName];
} else { // Custom Validators
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
} else if (validatorsToken) {
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
}
}
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`);
}
if (validatorArgs !== null) {
return validatorFn(validatorArgs);
}
return validatorFn;
},
isValidatorDescriptor(value) {
if (this.isObject(value)) {
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
}
return false;
},
isObject(value) {
return typeof value === 'object' && value !== null;
}
});
translateService = getMockTranslateService();
router = new RouterMock();
notificationService = new NotificationsServiceStub();
@@ -217,4 +284,114 @@ describe('GroupFormComponent', () => {
});
});
describe('check form validation', () => {
let groupCommunity;
beforeEach(() => {
groupName = 'testName';
groupCommunity = 'testgroupCommunity';
groupDescription = 'testgroupDescription';
expected = Object.assign(new Group(), {
name: groupName,
metadata: {
'dc.description': [
{
value: groupDescription
}
],
},
});
spyOn(component.submitForm, 'emit');
fixture.detectChanges();
component.initialisePage();
fixture.detectChanges();
});
describe('groupName, groupCommunity and groupDescription should be required', () => {
it('form should be invalid because the groupName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeFalse();
expect(component.formGroup.controls.groupName.errors.required).toBeTrue();
});
}));
});
describe('after inserting information groupName,groupCommunity and groupDescription not required', () => {
beforeEach(() => {
component.formGroup.controls.groupName.setValue('test');
fixture.detectChanges();
});
it('groupName should be valid because the groupName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeTrue();
expect(component.formGroup.controls.groupName.errors).toBeNull();
});
}));
});
describe('after already utilized groupName', () => {
beforeEach(() => {
const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected]));
}
});
component.formGroup.controls.groupName.setValue('testName');
component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup));
fixture.detectChanges();
});
it('groupName should not be valid because groupName is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeFalse();
expect(component.formGroup.controls.groupName.errors.groupExists).toBeTruthy();
});
}));
});
});
describe('delete', () => {
let deleteButton;
beforeEach(() => {
component.initialisePage();
component.canEdit$ = observableOf(true);
component.groupBeingEdited = {
permanent: false
} as Group;
fixture.detectChanges();
deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement;
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' }));
});
describe('if confirmed via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .confirm').click();
}));
it('should call GroupDataService.delete', () => {
expect(groupsDataServiceStub.delete).toHaveBeenCalledWith('active-group');
});
});
describe('if canceled via modal', () => {
beforeEach(waitForAsync(() => {
deleteButton.click();
fixture.detectChanges();
(document as any).querySelector('.modal-footer .cancel').click();
}));
it('should not call GroupDataService.delete', () => {
expect(groupsDataServiceStub.delete).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@@ -14,9 +14,9 @@ import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription
Subscription,
} from 'rxjs';
import { catchError, map, switchMap, take } from 'rxjs/operators';
import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators';
import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
@@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import {
getRemoteDataPayload,
getFirstSucceededRemoteData,
getFirstCompletedRemoteData
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/aletr-type';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
@@ -44,6 +45,7 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
@Component({
selector: 'ds-group-form',
@@ -65,6 +67,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* Dynamic models for the inputs of form
*/
groupName: DynamicInputModel;
groupCommunity: DynamicInputModel;
groupDescription: DynamicTextAreaModel;
/**
@@ -124,6 +127,12 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/
public AlertTypeEnum = AlertType;
/**
* Subscription to email field value change
*/
groupNameValueChangeSubscribe: Subscription;
constructor(public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
@@ -134,7 +143,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
protected router: Router,
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService) {
public requestService: RequestService,
protected changeDetectorRef: ChangeDetectorRef) {
}
ngOnInit() {
@@ -160,8 +170,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
);
observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`)
).subscribe(([groupName, groupDescription]) => {
).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({
id: 'groupName',
label: groupName,
@@ -171,6 +182,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
},
required: true,
});
this.groupCommunity = new DynamicInputModel({
id: 'groupCommunity',
label: groupCommunity,
name: 'groupCommunity',
required: false,
readOnly: true,
});
this.groupDescription = new DynamicTextAreaModel({
id: 'groupDescription',
label: groupDescription,
@@ -182,20 +200,51 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupDescription,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
if (!!this.formGroup.controls.groupName) {
this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService));
this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
this.subs.push(
observableCombineLatest(
this.groupDataService.getActiveGroup(),
this.canEdit$
).subscribe(([activeGroup, canEdit]) => {
this.canEdit$,
this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) {
// Disable group name exists validator
this.formGroup.controls.groupName.clearAsyncValidators();
this.groupBeingEdited = activeGroup;
if (linkedObject?.name) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
this.formGroup.patchValue({
groupName: activeGroup != null ? activeGroup.name : '',
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
groupName: activeGroup.name,
groupCommunity: linkedObject?.name ?? '',
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
} else {
this.formModel = [
this.groupName,
this.groupDescription,
];
this.formGroup.patchValue({
groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
setTimeout(() => {
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
}
}, 200);
}
})
);
@@ -377,7 +426,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
.subscribe((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name }));
this.reset();
this.onCancel();
} else {
this.notificationsService.error(
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }),
@@ -390,16 +439,6 @@ export class GroupFormComponent implements OnInit, OnDestroy {
});
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.groupDataService.getBrowseEndpoint().pipe(take(1)).subscribe((href: string) => {
this.requestService.removeByHrefSubstring(href);
});
this.onCancel();
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
@@ -407,6 +446,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.groupDataService.cancelEditGroup();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
if ( hasValue(this.groupNameValueChangeSubscribe) ) {
this.groupNameValueChangeSubscribe.unsubscribe();
}
}
/**
@@ -417,11 +461,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
return true;
} else {
return false;
}
return hasValue(rd) && hasValue(rd.payload);
}),
catchError(() => observableOf(false)),
);

View File

@@ -38,17 +38,22 @@
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<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.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
<td>{{ePerson.eperson.id}}</td>
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
<td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="(ePerson.memberOfGroup)"
(click)="deleteMemberFromGroup(ePerson)"
@@ -91,17 +96,22 @@
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<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.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
<td>{{ePerson.eperson.id}}</td>
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
<td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(ePerson)"
class="btn btn-outline-danger btn-sm"

View File

@@ -35,17 +35,19 @@
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<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>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
<td>{{group.id}}</td>
<td><a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="deleteSubgroupFromGroup(group)"
@@ -88,17 +90,19 @@
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<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>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td>{{group.id}}</td>
<td><a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"

View File

@@ -17,6 +17,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { NoContent } from '../../../../core/shared/NoContent.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
/**
* Keys to keep track of specific subscriptions
@@ -117,7 +118,10 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
currentPage: config.currentPage,
elementsPerPage: config.pageSize
}
},
true,
true,
followLink('object')
))
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.subGroups$.next(rd);
@@ -217,7 +221,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
currentPage: config.currentPage,
elementsPerPage: config.pageSize
}))
}, true, true, followLink('object')
))
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.searchResults$.next(rd);
}));

View File

@@ -0,0 +1,33 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable } from 'rxjs';
import { map} from 'rxjs/operators';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
import { Group } from '../../../../core/eperson/models/group.model';
export class ValidateGroupExists {
/**
* This method will create the validator with the groupDataService requested from component
* @param groupDataService the service with DI in the component that this validator is being utilized.
* @return Observable<ValidationErrors | null>
*/
static createValidator(groupDataService: GroupDataService) {
return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
return groupDataService.searchGroups(control.value, {
currentPage: 1,
elementsPerPage: 100
})
.pipe(
getFirstSucceededRemoteListPayload(),
map( (groups: Group[]) => {
return groups.filter(group => group.name === control.value);
}),
map( (groups: Group[]) => {
return groups.length > 0 ? { groupExists: true } : null;
}),
);
};
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store';
import { Group } from '../../core/eperson/models/group.model';
import { type } from '../../shared/ngrx/type';
@@ -16,7 +17,6 @@ export const GroupRegistryActionTypes = {
CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'),
};
/* tslint:disable:max-classes-per-file */
/**
* Used to edit a Group in the Group registry
*/
@@ -37,7 +37,6 @@ export class GroupRegistryCancelGroupAction implements Action {
type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP;
}
/* tslint:enable:max-classes-per-file */
/**
* Export a type alias of all actions in this action group

View File

@@ -48,6 +48,7 @@
<tr>
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
<th>{{messagePrefix + 'table.edit' | translate}}</th>
</tr>
@@ -56,6 +57,7 @@
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td>
<td>{{groupDto.group.name}}</td>
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td>
<div class="btn-group edit-field">
@@ -77,7 +79,7 @@
</button>
</ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>

View File

@@ -31,6 +31,7 @@ import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { NoContent } from '../../core/shared/NoContent.model';
describe('GroupRegistryComponent', () => {
let component: GroupsRegistryComponent;
@@ -145,13 +146,17 @@ describe('GroupRegistryComponent', () => {
totalPages: 1,
currentPage: 1
}), [result]));
}
},
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
};
dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return createSuccessfulRemoteDataObject$(undefined);
}
};
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
setIsAuthorized(true, true);
paginationService = new PaginationServiceStub();
@@ -200,6 +205,13 @@ describe('GroupRegistryComponent', () => {
});
});
it('should display community/collection name if present', () => {
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
expect(collectionNamesFound.length).toEqual(2);
expect(collectionNamesFound[0].nativeElement.textContent).toEqual('');
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
});
describe('edit buttons', () => {
describe('when the user is a general admin', () => {
beforeEach(fakeAsync(() => {
@@ -213,7 +225,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -247,7 +259,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -266,7 +278,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should not be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeTrue();
@@ -293,4 +305,29 @@ describe('GroupRegistryComponent', () => {
});
});
});
describe('delete', () => {
let deleteButton;
beforeEach(fakeAsync(() => {
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
setIsAuthorized(true, true);
// force rerender after setup changes
component.search({ query: '' });
tick();
fixture.detectChanges();
// only mockGroup[0] is deletable, so we should only get one button
deleteButton = fixture.debugElement.query(By.css('.btn-delete')).nativeElement;
}));
it('should call GroupDataService.delete', () => {
deleteButton.click();
fixture.detectChanges();
expect(groupsDataServiceStub.delete).toHaveBeenCalledWith(mockGroups[0].id);
});
});
});

View File

@@ -9,7 +9,7 @@ import {
of as observableOf,
Subscription
} from 'rxjs';
import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
@Component({
selector: 'ds-groups-registry',
@@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
}
return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize
});
elementsPerPage: paginationOptions.pageSize,
}, true, true, followLink('object'));
}),
getAllSucceededRemoteData(),
getRemoteDataPayload(),
@@ -198,7 +199,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) {
this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id];
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name }));
this.reset();
} else {
this.notificationsService.error(
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }),
@@ -208,17 +208,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
}
}
/**
* This method will set everything to stale, which will cause the lists on this page to update.
*/
reset() {
this.groupService.getBrowseEndpoint().pipe(
take(1)
).subscribe((href: string) => {
this.requestService.setStaleByHrefSubstring(href);
});
}
/**
* Get the members (epersons embedded value of a group)
* @param group

View File

@@ -1,6 +1,17 @@
<div class="container">
<h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2>
<p>{{'admin.metadata-import.page.help' | translate}}</p>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly">
{{'admin.metadata-import.page.validateOnly' | translate}}
</label>
</div>
<small id="validateOnlyHelpBlock" class="form-text text-muted">
{{'admin.metadata-import.page.validateOnly.hint' | translate}}
</small>
</div>
<ds-file-dropzone-no-uploader
(onFileAdded)="setFile($event)"
@@ -8,8 +19,10 @@
[dropMessageLabelReplacement]="'admin.metadata-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader>
<div class="space-children-mr">
<button class="btn btn-secondary" id="backButton"
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
<button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div>
</div>

View File

@@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => {
comp.setFile(fileMock);
});
describe('if proceed button is pressed', () => {
describe('if proceed button is pressed without validate only', () => {
beforeEach(fakeAsync(() => {
comp.validateOnly = false;
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
proceed.click();
fixture.detectChanges();
@@ -107,6 +108,28 @@ describe('MetadataImportPageComponent', () => {
});
});
describe('if proceed button is pressed with validate only', () => {
beforeEach(fakeAsync(() => {
comp.validateOnly = true;
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
proceed.click();
fixture.detectChanges();
}));
it('metadata-import script is invoked with -f fileName and the mockFile and -v validate-only', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }),
Object.assign(new ProcessParameter(), { name: '-v', value: true }),
];
expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
});
it('success notification is shown', () => {
expect(notificationService.success).toHaveBeenCalled();
});
it('redirected to process page', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
});
});
describe('if proceed is pressed; but script invoke fails', () => {
beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true);

View File

@@ -30,6 +30,11 @@ export class MetadataImportPageComponent {
*/
fileObject: File;
/**
* The validate only flag
*/
validateOnly = true;
public constructor(private location: Location,
protected translate: TranslateService,
protected notificationsService: NotificationsService,
@@ -62,6 +67,9 @@ export class MetadataImportPageComponent {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }),
];
if (this.validateOnly) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true }));
}
this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
getFirstCompletedRemoteData(),

View File

@@ -8,6 +8,7 @@ import { SharedModule } from '../../shared/shared.module';
import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component';
import { MetadataFieldFormComponent } from './metadata-schema/metadata-field-form/metadata-field-form.component';
import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.module';
import { FormModule } from '../../shared/form/form.module';
@NgModule({
imports: [
@@ -15,7 +16,8 @@ import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.mo
SharedModule,
RouterModule,
BitstreamFormatsModule,
AdminRegistriesRoutingModule
AdminRegistriesRoutingModule,
FormModule
],
declarations: [
MetadataRegistryComponent,

Some files were not shown because too many files have changed in this diff Show More