diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000000..326dd3e0b29 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,28 @@ +# DSpace configuration for Codecov.io coverage reports +# These override the default YAML settings at +# https://docs.codecov.io/docs/codecov-yaml#section-default-yaml +# Can be validated via instructions at: +# https://docs.codecov.io/docs/codecov-yaml#validate-your-repository-yaml + +# Settings related to code coverage analysis +coverage: + status: + # Configuration for project-level checks. This checks how the PR changes overall coverage. + project: + default: + # For each PR, auto compare coverage to previous commit. + # Require that overall (project) coverage does NOT drop more than 0.5% + target: auto + threshold: 0.5% + # Configuration for patch-level checks. This checks the relative coverage of the new PR code ONLY. + patch: + default: + # Enable informational mode, which just provides info to reviewers & always passes + # https://docs.codecov.io/docs/commit-status#section-informational + informational: true + +# Turn PR comments "off". This feature adds the code coverage summary as a +# comment on each PR. See https://docs.codecov.io/docs/pull-request-comments +# However, this same info is available from the Codecov checks in the PR's +# "Checks" tab in GitHub. So, the comment is unnecessary. +comment: false diff --git a/.editorconfig b/.editorconfig index 70ce43b68ed..15d4c87b142 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,6 @@ trim_trailing_whitespace = true [*.md] insert_final_newline = false trim_trailing_whitespace = false + +[*.ts] +quote_type = single diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..8e4ed0811d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug, needs triage +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. Include the version(s) of DSpace where you've seen this problem & what *web browser* you were using. Link to examples if they are public. + +**To Reproduce** +Steps to reproduce the behavior: +1. Do this +2. Then this... + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Related work** +Link to any related tickets or PRs here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..34cc2c9e4f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest a new feature for this project +title: '' +labels: new feature, needs triage +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives or workarounds you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/disabled-workflows/pull_request_opened.yml b/.github/disabled-workflows/pull_request_opened.yml new file mode 100644 index 00000000000..0dc718c0b9a --- /dev/null +++ b/.github/disabled-workflows/pull_request_opened.yml @@ -0,0 +1,26 @@ +# This workflow runs whenever a new pull request is created +# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs). +# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818 +name: Pull Request opened + +# Only run for newly opened PRs against the "main" branch +on: + pull_request: + types: [opened] + branches: + - main + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards + # See https://github.com/marketplace/actions/pull-request-assigner + - name: Assign PR to creator + uses: thomaseizinger/assign-pr-creator-action@v1.0.0 + # Note, this authentication token is created automatically + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Ignore errors. It is possible the PR was created by someone who cannot be assigned + continue-on-error: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 682f67294b8..be15b0a507c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## References -_Add references/links to any related tickets or PRs. These may include:_ -* Link to [Angular issue or PR](https://github.com/DSpace/dspace-angular/issues) related to this PR, if any -* Link to [JIRA](https://jira.lyrasis.org/projects/DS/summary) ticket(s), if any +_Add references/links to any related issues or PRs. These may include:_ +* Fixes #[issue-number] +* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this) ## Description Short summary of changes (1-2 sentences). @@ -20,9 +20,7 @@ _This checklist provides a reminder of what we are going to look for when review - [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. - [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` +- [ ] My PR doesn't introduce circular dependencies - [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. -- [ ] My PR passes all specs/tests and includes new/updated specs for any bug fixes, improvements or new features. A few reminders about what constitutes good tests: - * Include tests for different user types (if behavior differs), including: (1) Anonymous user, (2) Logged in user (non-admin), and (3) Administrator. - * Include tests for error scenarios, e.g. when errors/warnings should appear (or buttons should be disabled). - * For bug fixes, include a test that reproduces the bug and proves it is fixed. For clarity, it may be useful to provide the test in a separate commit from the bug fix. -- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/master/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. +- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). +- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000000..0495f51fc2c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,87 @@ +# DSpace Continuous Integration/Build via GitHub Actions +# Concepts borrowed from +# https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-nodejs +name: Build + +# Run this Build for all pushes / PRs to current branch +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + env: + # The ci step will test the dspace-angular code against DSpace REST. + # Direct that step to utilize a DSpace REST service that has been started in docker. + DSPACE_REST_HOST: localhost + DSPACE_REST_PORT: 8080 + DSPACE_REST_NAMESPACE: '/server' + DSPACE_REST_SSL: false + strategy: + # Create a matrix of Node versions to test against (in parallel) + matrix: + node-version: [10.x, 12.x] + # Do NOT exit immediately if one matrix job fails + fail-fast: false + # These are the actual CI steps to perform per job + steps: + # https://github.com/actions/checkout + - name: Checkout codebase + uses: actions/checkout@v1 + + # https://github.com/actions/setup-node + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install latest Chrome (for e2e tests) + run: | + sudo apt-get update + sudo apt-get --only-upgrade install google-chrome-stable -y + google-chrome --version + + # https://github.com/actions/cache/blob/main/examples.md#node---yarn + - name: Get Yarn cache directory + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: Cache Yarn dependencies + uses: actions/cache@v2 + with: + # Cache entire Yarn cache directory (see previous step) + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + # Cache key is hash of yarn.lock. Therefore changes to yarn.lock will invalidate cache + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn- + + - name: Install Yarn dependencies + run: yarn install --frozen-lockfile + + - name: Run lint + run: yarn run lint + + - name: Run build + run: yarn run build:prod + + - name: Run specs (unit tests) + run: yarn run test:headless + + # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 + # 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 + if: matrix.node-version == '12.x' + + # Using docker-compose start backend using CI configuration + # and load assetstore from a cached copy + - name: Start DSpace REST Backend via Docker (for e2e tests) + run: | + docker-compose -f ./docker/docker-compose-ci.yml up -d + docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + docker container ls + + - name: Run e2e tests (integration tests) + run: yarn run e2e:ci + + - name: Shutdown Docker containers + run: docker-compose -f ./docker/docker-compose-ci.yml down diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml new file mode 100644 index 00000000000..6b9a273ab6d --- /dev/null +++ b/.github/workflows/issue_opened.yml @@ -0,0 +1,29 @@ +# This workflow runs whenever a new issue is created +name: Issue opened + +on: + issues: + types: [opened] + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Add the new issue to a project board, if it needs triage + # See https://github.com/marketplace/actions/create-project-card-action + - name: Add issue to project board + # Only add to project board if issue is flagged as "needs triage" or has no labels + # NOTE: By default we flag new issues as "needs triage" in our issue template + if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') + uses: technote-space/create-project-card-action@v1 + # Note, the authentication token below is an ORG level Secret. + # It must be created/recreated manually via a personal access token with "public_repo" and "admin:org" permissions + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token + # This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific) + with: + GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }} + PROJECT: DSpace Backlog + COLUMN: Triage + CHECK_ORG_PROJECT: true + # Ignore errors + continue-on-error: true diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml new file mode 100644 index 00000000000..dcbab18f1b5 --- /dev/null +++ b/.github/workflows/label_merge_conflicts.yml @@ -0,0 +1,25 @@ +# This workflow checks open PRs for merge conflicts and labels them when conflicts are found +name: Check for merge conflicts + +# Run whenever the "main" branch is updated +# NOTE: This means merge conflicts are only checked for when a PR is merged to main. +on: + push: + branches: + - main + +jobs: + triage: + runs-on: ubuntu-latest + steps: + # See: https://github.com/mschilde/auto-label-merge-conflicts/ + - name: Auto-label PRs with merge conflicts + uses: mschilde/auto-label-merge-conflicts@v2.0 + # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved. + # Note, the authentication token is created automatically + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token + with: + CONFLICT_LABEL_NAME: 'merge conflict' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Ignore errors + continue-on-error: true diff --git a/.gitignore b/.gitignore index 6950bea78de..f110ba720c7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ package-lock.json .java-version .env +/nbproject/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0d65d76f41a..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,57 +0,0 @@ -sudo: required -dist: bionic -language: node_js - -# Enable caching for yarn & node_modules -cache: - yarn: true - -node_js: - - "10" - - "12" - -# Install latest chrome (for e2e headless testing). Run an update if needed. -addons: - apt: - sources: - - google-chrome - packages: - - google-chrome-stable - update: true - -env: - # The ci step will test the dspace-angular code against DSpace REST. - # Direct that step to utilize a DSpace REST service that has been started in docker. - DSPACE_REST_HOST: localhost - DSPACE_REST_PORT: 8080 - DSPACE_REST_NAMESPACE: '/server/api' - DSPACE_REST_SSL: false - -before_install: - # Check our versions of everything - - echo "Check versions" - - yarn -v - - docker-compose -v - - google-chrome-stable --version - -install: - # Start up DSpace 7 using the entities database dump - - docker-compose -f ./docker/docker-compose-travis.yml up -d - # Use the dspace-cli image to populate the assetstore. Triggers a discovery and oai update - - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli - - travis_retry yarn install - -before_script: - - echo "Check Docker containers" - - docker container ls - # The following line could be enabled to verify that the rest server is responding. - #- echo "Check REST API available (via Docker)" - #- curl http://localhost:8080/server/ - -script: - - yarn run ci - - cat coverage/dspace-angular-cli/lcov.info | ./node_modules/coveralls/bin/coveralls.js - -after_script: - # Shutdown docker after everything runs - - docker-compose -f ./docker/docker-compose-travis.yml down diff --git a/README.md b/README.md index 78d7816f659..4b4c7dc6cfb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/DSpace/dspace-angular.svg?branch=master)](https://travis-ci.org/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=master)](https://coveralls.io/github/DSpace/dspace-angular?branch=master) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) +[![Build Status](https://github.com/DSpace/dspace-angular/workflows/Build/badge.svg?branch=main)](https://github.com/DSpace/dspace-angular/actions?query=workflow%3ABuild) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) dspace-angular ============== @@ -104,11 +104,12 @@ DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false] The same settings can also be overwritten by setting system environment variables instead, E.g.: ```bash -export DSPACE_HOST=https://dspace7.4science.cloud/server +export DSPACE_HOST=dspace7.4science.cloud ``` The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides **`environment.(prod, dev or test).ts`** overrides **`environment.common.ts`** +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. #### Using environment variables in code To use environment variables in a UI component, use: @@ -134,14 +135,14 @@ To build the app for production and start the server run: ```bash yarn start ``` +This will run the application in an instance of the Express server, which is included. If you only want to build for production, without starting, run: ```bash yarn run build:prod ``` - -This will build the application and put the result in the `dist` folder +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`. ### Running the application with Docker diff --git a/angular.json b/angular.json index b3fbd82f022..fcb2d968f01 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,7 @@ "version": 1, "newProjectRoot": "projects", "projects": { - "dspace-angular-cli": { + "dspace-angular": { "projectType": "application", "schematics": { "@schematics/angular:component": { @@ -18,7 +18,7 @@ "builder": "@angular-builders/custom-webpack:browser", "options": { "customWebpackConfig": { - "path": "./webpack/webpack.common.ts", + "path": "./webpack/webpack.browser.ts", "mergeStrategies": { "loaders": "prepend" } @@ -30,7 +30,8 @@ "tsConfig": "tsconfig.app.json", "aot": false, "assets": [ - "src/assets" + "src/assets", + "src/robots.txt" ], "styles": [ "src/styles.scss" @@ -65,26 +66,26 @@ "serve": { "builder": "@angular-builders/custom-webpack:dev-server", "options": { - "browserTarget": "dspace-angular-cli:build", + "browserTarget": "dspace-angular:build", "port": 4000 }, "configurations": { "production": { - "browserTarget": "dspace-angular-cli:build:production" + "browserTarget": "dspace-angular:build:production" } } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "dspace-angular-cli:build" + "browserTarget": "dspace-angular:build" } }, "test": { "builder": "@angular-builders/custom-webpack:karma", "options": { "customWebpackConfig": { - "path": "./webpack/webpack.common.ts", + "path": "./webpack/webpack.test.ts", "mergeStrategies": { "loaders": "prepend" } @@ -93,6 +94,12 @@ "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", + "sourceMap": { + "scripts": false, + "styles": false, + "hidden": false, + "vendor": false + }, "assets": [ "src/assets" ], @@ -119,11 +126,11 @@ "builder": "@angular-devkit/build-angular:protractor", "options": { "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "dspace-angular-cli:serve" + "devServerTarget": "dspace-angular:serve" }, "configurations": { "production": { - "devServerTarget": "dspace-angular-cli:serve:production" + "devServerTarget": "dspace-angular:serve:production" } } }, @@ -153,5 +160,5 @@ } } }, - "defaultProject": "dspace-angular-cli" + "defaultProject": "dspace-angular" } diff --git a/docker/README.md b/docker/README.md index f7b4b048482..809a150d866 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,13 +5,13 @@ - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. - docker-compose-rest.yml - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes -- docker-compose-travis.yml +- docker-compose-ci.yml - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. - cli.yml - 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.js +- environment.dev.ts - Environment file for running DSpace Angular in Docker - local.cfg - Environment file for running the DSpace 7 REST API in Docker. diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 075c494a6cf..592194a7528 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -1,3 +1,17 @@ +# +# 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 is a copy of the cli.ingest.yml that is available in the DSpace/DSpace +# (Backend) at: +# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.assetstore.yml +# +# Therefore, it should be kept in sync with that file version: "3.7" networks: @@ -8,7 +22,7 @@ services: networks: dspacenet: {} environment: - - LOADASSETS=https://www.dropbox.com/s/zv7lj8j2lp3egjs/assetstore.tar.gz?dl=1 + - LOADASSETS=https://www.dropbox.com/s/v3ahfcuatklbmi0/assetstore-2019-11-28.tar.gz?dl=1 entrypoint: - /bin/bash - '-c' @@ -21,3 +35,5 @@ services: fi /dspace/bin/dspace index-discovery + /dspace/bin/dspace oai import + /dspace/bin/dspace oai clean-cache diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml index f5ec7eb90d4..1db241af3bf 100644 --- a/docker/cli.ingest.yml +++ b/docker/cli.ingest.yml @@ -6,12 +6,18 @@ # http://www.dspace.org/license/ # +# +# This is a copy of the cli.ingest.yml that is available in the DSpace/DSpace +# (Backend) at: +# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.ingest.yml +# +# Therefore, it should be kept in sync with that file version: "3.7" services: dspace-cli: environment: - - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/master/dogAndReport.zip + - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip - ADMIN_EMAIL=test@test.edu - AIPDIR=/tmp/aip-dir entrypoint: diff --git a/docker/cli.yml b/docker/cli.yml index ea5e3e05951..36f63b2cff2 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -1,3 +1,17 @@ +# +# 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 is a copy of the docker-compose-cli.yml that is available in the DSpace/DSpace +# (Backend) at: +# https://github.com/DSpace/DSpace/blob/main/docker-compose-cli.yml +# +# Therefore, it should be kept in sync with that file version: "3.7" services: diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 91d96bd72b4..d39eedc5c68 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -6,11 +6,17 @@ # http://www.dspace.org/license/ # +# +# This is a copy of the db.entities.yml that is available in the DSpace/DSpace +# (Backend) at: +# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml +# +# # Therefore, it should be kept in sync with that file version: "3.7" services: dspacedb: image: dspace/dspace-postgres-pgcrypto:loadsql environment: - # Double underbars in env names will be replaced with periods for apache commons - - LOADSQL=https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1 + # This LOADSQL should be kept in sync with the URL in DSpace/DSpace + - LOADSQL=https://www.dropbox.com/s/4ap1y6deseoc8ws/dspace7-entities-2019-11-28.sql?dl=1 diff --git a/docker/docker-compose-travis.yml b/docker/docker-compose-ci.yml similarity index 64% rename from docker/docker-compose-travis.yml rename to docker/docker-compose-ci.yml index f0f5ef70e8d..ec2d0912cfa 100644 --- a/docker/docker-compose-travis.yml +++ b/docker/docker-compose-ci.yml @@ -1,3 +1,13 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +# Docker Compose for running the DSpace backend for e2e testing in a CI environment +# This is used by our GitHub CI at .github/workflows/build.yml networks: dspacenet: services: @@ -19,7 +29,9 @@ services: dspacedb: container_name: dspacedb environment: - LOADSQL: https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1 + # 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 + LOADSQL: https://www.dropbox.com/s/4ap1y6deseoc8ws/dspace7-entities-2019-11-28.sql?dl=1 PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index 6f5a1d6c83c..e6668f3f58d 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -1,11 +1,24 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +# Docker Compose for running the DSpace backend for testing/development +# This is based heavily on the docker-compose.yml that is available in the DSpace/DSpace +# (Backend) at: +# https://github.com/DSpace/DSpace/blob/main/docker-compose.yml +version: '3.7' networks: dspacenet: services: dspace: container_name: dspace + image: dspace/dspace:dspace-7_x-test depends_on: - dspacedb - image: dspace/dspace:dspace-7_x-test networks: dspacenet: ports: @@ -16,20 +29,27 @@ services: volumes: - assetstore:/dspace/assetstore - "./local.cfg:/dspace/config/local.cfg" - # Ensure that the database is ready before starting tomcat + # Ensure that the database is ready BEFORE starting tomcat + # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep + # 2. Then, run database migration to init database tables + # 3. Finally, start Tomcat entrypoint: - /bin/bash - '-c' - | + while (! /dev/null 2>&1; do sleep 1; done; /dspace/bin/dspace database migrate catalina.sh run dspacedb: container_name: dspacedb - image: dspace/dspace-postgres-pgcrypto environment: PGDATA: /pgdata + image: dspace/dspace-postgres-pgcrypto networks: dspacenet: + ports: + - published: 5432 + target: 5432 stdin_open: true tty: true volumes: @@ -49,7 +69,6 @@ services: - solr_oai:/opt/solr/server/solr/oai/data - solr_search:/opt/solr/server/solr/search/data - solr_statistics:/opt/solr/server/solr/statistics/data -version: '3.7' volumes: assetstore: pgdata: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 05376cfb360..7c5c3269590 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,3 +1,14 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +# Docker Compose for running the DSpace Angular UI for testing/development +# Requires also running a REST API backend (either locally or remotely), +# for example via 'docker-compose-rest.yml' version: '3.7' networks: dspacenet: @@ -23,4 +34,4 @@ services: stdin_open: true tty: true volumes: - - ./environment.dev.js:/app/src/environments/environment.dev.ts + - ./environment.dev.ts:/app/src/environments/environment.dev.ts diff --git a/docker/environment.dev.ts b/docker/environment.dev.ts index e8c88112fa9..0e603ef11d4 100644 --- a/docker/environment.dev.ts +++ b/docker/environment.dev.ts @@ -1,18 +1,18 @@ -/* +/** * 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/ */ -import { GlobalConfig } from '../src/config/global-config.interface'; - -export const environment: Partial = { +// 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/api' + nameSpace: '/server' } }; diff --git a/docker/local.cfg b/docker/local.cfg index 70bc45c112c..a511c25789d 100644 --- a/docker/local.cfg +++ b/docker/local.cfg @@ -1,5 +1,6 @@ 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 diff --git a/docs/Configuration.md b/docs/Configuration.md index f523a9a1a10..ac5ca3ef722 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,9 +1,9 @@ # Configuration -Default configuration file is located in `config/` folder. All configuration options should be listed in the default configuration file `config/environment.default.js`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change: +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. -- Create a new `environment.dev.js` file in `config/` for `devel` environment; -- Create a new `environment.prod.js` file in `config/` for `production` environment; +- 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; Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below. @@ -12,8 +12,8 @@ When you start dspace-angular on node, it spins up an http server on which it li To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above): ``` -module.exports = { - // Angular Universal server settings. +export const environment = { + // Angular UI settings. ui: { ssl: false, host: 'localhost', @@ -35,14 +35,14 @@ Alternately you can set the following environment variables. If any of these are 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: ``` -module.exports = { +export const environment = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7.4science.it', + host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/dspace-spring-rest/api' + nameSpace: '/server' } }; ``` @@ -50,9 +50,9 @@ module.exports = { Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: ``` DSPACE_REST_SSL=true - DSPACE_REST_HOST=localhost - DSPACE_REST_PORT=4000 - DSPACE_REST_NAMESPACE=/ + DSPACE_REST_HOST=dspace7.4science.cloud + DSPACE_REST_PORT=443 + DSPACE_REST_NAMESPACE=/server ``` ## Supporting analytics services other than Google Analytics @@ -63,3 +63,70 @@ Angulartics can be configured to work with a number of other services besides Go In order to start using one of these services, select it from the [Angulartics Providers page](https://angulartics.github.io/angulartics2/#providers), and follow the instructions on how to configure it. The Google Analytics script was added in [`main.browser.ts`](https://github.com/DSpace/dspace-angular/blob/ff04760f4af91ac3e7add5e7424a46cb2439e874/src/main.browser.ts#L33) instead of the `` tag in `index.html` to ensure events get sent when the page is shown in a client's browser, and not when it's rendered on the universal server. Likely you'll want to do the same when adding a new service. + +## SEO when hosting REST Api and UI on different servers + +Indexers such as Google Scholar require that files are hosted on the same domain as the page that links them. In DSpace 7, Bitstreams are served from the REST server. So if you use different servers for the REST api and the UI you'll want to ensure that Bitstream downloads are proxied through the UI server. + +In order to achieve this we'll need to do two things: +- **Proxy the Bitstream downloads through the UI server.** You'll need to put a webserver such as httpd or nginx in front of the UI server in order to achieve this. [Below](#apache-http-server-config) you'll find a section explaining how to do it in httpd. +- **Update the URLs for Bitstream downloads to match the UI server.** This can be done using a setting in the UI environment file. + +### UI config +If you set the property `rewriteDownloadUrls` to `true` in your `environment.prod.ts` file, the [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin) of any download URL will be replaced by the origin of the UI. This will also happen for the `citation_pdf_url` `` tag on Item pages. + +The app will determine the UI origin currently in use, so the external UI URL doesn't need to be configured anywhere and rewrites will still work if you host the UI from multiple domains. + +### Apache HTTP Server config + +#### Basics +In order to be able to host bitstreams from the UI Server you'll need to enable mod_proxy and add the following to the httpd config of your UI server: + +``` +ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content" +ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content" +``` + +Replace http://rest.api in with the correct origin for your REST server. + +The `ProxyPassMatch` line forwards all requests matching the regular expression for a bitstream download URL to the corresponding path on the REST server + +The `ProxyPassReverse` ensures that if the REST server were to return redirect response, httpd would also swap out its hostname for the hostname of the UI before forwarding the response to the client. + +#### Using HTTPS +If your REST server uses https, you'll need to enable mod_ssl and ensure `SSLProxyEngine on` is part of your UI server's httpd config as well + +If the UI hostname doesn't match the CN in the SSL certificate of the REST server (which is likely if they're on different domains), you'll also need to add the following lines + +``` +SSLProxyCheckPeerCN off +SSLProxyCheckPeerName off +``` +These are two names for [the same directive](https://httpd.apache.org/docs/trunk/mod/mod_ssl.html#sslproxycheckpeername) that have been used for various versions of httpd, old versions need the former, then some in-between versions need both, and newer versions only need the latter. Keeping them both doesn't harm anything. + +So the entire config becomes: + +``` +SSLProxyEngine on +SSLProxyCheckPeerCN off +SSLProxyCheckPeerName off +ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" +ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" +``` + +If you don't want httpd to verify the certificate of the REST server, you can also turn all checks off with the following config: + +``` +SSLProxyEngine on +SSLProxyVerify none +SSLProxyCheckPeerCN off +SSLProxyCheckPeerName off +SSLProxyCheckPeerExpire off +ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" +ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content" +``` + + + + + diff --git a/karma.conf.js b/karma.conf.js index 9844d659044..a3b6803e6d4 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -17,7 +17,7 @@ module.exports = function (config) { clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { - dir: require('path').join(__dirname, './coverage/dspace-angular-cli'), + dir: require('path').join(__dirname, './coverage/dspace-angular'), reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, diff --git a/package.json b/package.json index 4c6bd31cacf..60473fbffc2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "dspace-angular-cli", + "name": "dspace-angular", "version": "0.0.0", "scripts": { "ng": "ng", @@ -17,13 +17,13 @@ "pree2e": "yarn run config:prod", "pree2e:ci": "yarn run config:prod", "start": "yarn run start:prod", - "serve": "ng serve", + "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", "build": "ng build", "build:prod": "yarn run build:ssr", "build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server", - "build:client-and-server-bundles": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --prod && ng run dspace-angular-cli:server:production --bundleDependencies all", + "build:client-and-server-bundles": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng build --prod && ng run dspace-angular:server:production --bundleDependencies all", "test:watch": "npm-run-all --parallel config:test:watch test", "test": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng test --sourceMap=true --watch=true", "test:headless": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", @@ -32,7 +32,6 @@ "e2e:ci": "ng e2e --protractor-config=./e2e/protractor-ci.conf.js", "compile:server": "webpack --config webpack.server.config.js --progress --colors", "serve:ssr": "node dist/server", - "ci": "ng lint && yarn run build:prod && yarn test:headless && yarn run e2e:ci", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", "clean:doc": "rimraf doc", @@ -41,7 +40,7 @@ "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:node && yarn run clean:env", + "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" }, @@ -52,6 +51,9 @@ "https": false }, "private": true, + "resolutions": { + "minimist": "^1.2.5" + }, "dependencies": { "@angular/animations": "~8.2.14", "@angular/cdk": "8.2.3", @@ -86,18 +88,21 @@ "debug-loader": "^0.0.1", "deepmerge": "^4.2.2", "express": "4.16.2", + "express-rate-limit": "^5.1.3", "fast-json-patch": "^2.0.7", "file-saver": "^1.3.8", + "filesize": "^6.1.0", "font-awesome": "4.7.0", "https": "1.0.0", "js-cookie": "2.2.0", "json5": "^2.1.0", "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", + "klaro": "^0.6.3", "moment": "^2.22.1", "morgan": "^1.9.1", "ng-mocks": "^8.1.0", - "ng2-file-upload": "1.2.1", + "ng2-file-upload": "1.4.0", "ng2-nouislider": "^1.8.2", "ngx-bootstrap": "^5.3.2", "ngx-infinite-scroll": "6.0.1", @@ -136,17 +141,17 @@ "codelyzer": "^5.0.0", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^5.1.1", - "coveralls": "3.0.0", "css-loader": "3.4.0", "cssnano": "^4.1.10", "deep-freeze": "0.0.1", "dotenv": "^8.2.0", "fork-ts-checker-webpack-plugin": "^0.4.10", "html-webpack-plugin": "^3.2.0", + "http-proxy-middleware": "^1.0.5", "jasmine-core": "^3.3.0", "jasmine-marbles": "0.3.1", "jasmine-spec-reporter": "~4.2.1", - "karma": "~4.1.0", + "karma": "^5.0.9", "karma-chrome-launcher": "~2.2.0", "karma-coverage-istanbul-reporter": "~2.0.1", "karma-jasmine": "2.0.1", @@ -157,10 +162,10 @@ "optimize-css-assets-webpack-plugin": "^5.0.1", "postcss-apply": "0.11.0", "postcss-cssnext": "3.1.0", + "postcss-import": "^12.0.1", "postcss-loader": "^3.0.0", "postcss-responsive-type": "1.0.0", - "postcss-smart-import": "^0.7.6", - "protractor": "~5.4.0", + "protractor": "^7.0.0", "protractor-istanbul-plugin": "2.0.0", "raw-loader": "0.5.1", "rimraf": "^3.0.2", diff --git a/postcss.config.js b/postcss.config.js index c499f9da90c..1c46e245eaa 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: [ - require('postcss-smart-import')(), + require('postcss-import')(), require('postcss-cssnext')(), require('postcss-apply')(), require('postcss-responsive-type')() diff --git a/scripts/serve.ts b/scripts/serve.ts new file mode 100644 index 00000000000..c69f8e8a215 --- /dev/null +++ b/scripts/serve.ts @@ -0,0 +1,11 @@ +import { environment } from '../src/environments/environment'; + +import * as child from 'child_process'; + +/** + * Calls `ng serve` with the following arguments configured for the UI in the environment file: host, port, nameSpace, ssl + */ +child.spawn( + `ng serve --host ${environment.ui.host} --port ${environment.ui.port} --servePath ${environment.ui.nameSpace} --ssl ${environment.ui.ssl}`, + { stdio:'inherit', shell: true } +); diff --git a/server.ts b/server.ts index 31cefe4ec5b..202d5a58bcb 100644 --- a/server.ts +++ b/server.ts @@ -15,28 +15,71 @@ * import for `ngExpressEngine`. */ -import 'zone.js/dist/zone-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 { join } from 'path'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import * as bodyParser from 'body-parser'; +import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; -import { environment } from './src/environments/environment'; +import { join } from 'path'; -// Express server -const app = express(); +import { enableProdMode } from '@angular/core'; + +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { environment } from './src/environments/environment'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; +import { UIServerConfig } from './src/config/ui-server-config.interface'; -const PORT = environment.ui.port || 4000; +/* + * Set path for the browser application's dist folder + */ const DIST_FOLDER = join(process.cwd(), 'dist/browser'); // * NOTE :: leave this as require() since this file is built Dynamically from webpack const { ServerAppModuleNgFactory, LAZY_MODULE_MAP, ngExpressEngine, provideModuleMap } = require('./dist/server/main'); +/* + * Create a new express application + */ +const app = 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) + */ +if (environment.production) { + enableProdMode(); + app.use(compression()); +} + +/* + * Enable request logging + * See [morgan](https://github.com/expressjs/morgan) + */ +app.use(morgan('dev')); + +/* + * Add cookie parser middleware + * See [morgan](https://github.com/expressjs/cookie-parser) + */ app.use(cookieParser()); + +/* + * Add parser for request bodies + * See [morgan](https://github.com/expressjs/body-parser) + */ app.use(bodyParser.json()); +/* + * Render html pages by running angular server side + */ app.engine('html', (_, options, callback) => ngExpressEngine({ bootstrap: ServerAppModuleNgFactory, @@ -51,25 +94,148 @@ app.engine('html', (_, options, callback) => }, provideModuleMap(LAZY_MODULE_MAP) ], - })(_, options, callback) + })(_, (options as any), callback) ); +/* + * Register the view engines for html and ejs + */ app.set('view engine', 'html'); + +/* + * Set views folder path to directory where template files are stored + */ app.set('views', DIST_FOLDER); -// Example Express Rest API endpoints -// app.get('/api/**', (req, res) => { }); -// Serve static files from /browser -app.get('*.*', express.static(DIST_FOLDER, { - maxAge: '1y' -})); - -// All regular routes use the Universal engine -app.get('*', (req, res) => { - res.render('index', { req }); -}); - -// Start up the Node server -app.listen(PORT, () => { - console.log(`Node Express server listening on http://localhost:${PORT}`); -}); +/** + * Proxy the sitemaps + */ +app.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); + +/* + * Adds a cache control header to the response + * The cache control value can be configured in the environments file and defaults to max-age=60 + */ +function cacheControl(req, res, next) { + // instruct browser to revalidate + res.header('Cache-Control', environment.cache.control || 'max-age=60'); + next(); +} + +/** + * Checks if the rateLimiter property is present + * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. + */ +if (hasValue((environment.ui as UIServerConfig).rateLimiter)) { + const RateLimit = require('express-rate-limit'); + const limiter = new RateLimit({ + windowMs: (environment.ui as UIServerConfig).rateLimiter.windowMs, + max: (environment.ui as UIServerConfig).rateLimiter.max + }); + app.use(limiter); +} + +/* + * Serve static resources (images, i18n messages, …) + */ +app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); + +/* + * The callback function to serve server side angular + */ +function ngApp(req, res) { + if (environment.universal.preboot) { + res.render(DIST_FOLDER + '/index.html', { + req, + res, + preboot: environment.universal.preboot, + async: environment.universal.async, + time: environment.universal.time, + baseUrl: environment.ui.nameSpace, + originUrl: environment.ui.baseUrl, + requestUrl: req.originalUrl + }, (err, data) => { + if (hasNoValue(err) && hasValue(data)) { + res.send(data); + } else { + console.warn('Error in SSR, serving for direct CSR.'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + res.sendFile(DIST_FOLDER + '/index.html'); + } + }) + } else { + // If preboot is disabled, just serve the client + console.log('Universal off, serving for direct CSR'); + res.sendFile(DIST_FOLDER + '/index.html'); + } +} + +// Register the ngApp callback function to handle incoming requests +app.get('*', ngApp); + +/* + * Callback function for when the server has started + */ +function serverStarted() { + console.log(`[${new Date().toTimeString()}] Listening at ${environment.ui.baseUrl}`); +} + +/* + * Create an HTTPS server with the configured port and host + * @param keys SSL credentials + */ +function createHttpsServer(keys) { + https.createServer({ + key: keys.serviceKey, + cert: keys.certificate + }, app).listen(environment.ui.port, environment.ui.host, () => { + serverStarted(); + }); +} + +/* + * If SSL is enabled + * - Read credentials from configuration files + * - Call script to start an HTTPS server with these credentials + * When SSL is disabled + * - Start an HTTP server on the configured port and host + */ +if (environment.ui.ssl) { + let serviceKey; + try { + serviceKey = fs.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'); + } catch (e) { + console.warn('Certificate not found at ./config/ssl/key.pem'); + } + + if (serviceKey && certificate) { + createHttpsServer({ + serviceKey: serviceKey, + certificate: certificate + }); + } else { + console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); + + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] + + pem.createCertificate({ + days: 1, + selfSigned: true + }, (error, keys) => { + createHttpsServer(keys); + }); + } +} else { + app.listen(environment.ui.port, environment.ui.host, () => { + serverStarted(); + }); +} diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts b/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts new file mode 100644 index 00000000000..2080cb14a78 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts @@ -0,0 +1,8 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getAccessControlModuleRoute } from '../admin-routing-paths'; + +export const GROUP_EDIT_PATH = 'groups'; + +export function getGroupEditRoute(id: string) { + return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); +} diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts index 5af18c778f6..10ac117b0f9 100644 --- a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -3,14 +3,7 @@ import { RouterModule } from '@angular/router'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { getAccessControlModulePath } from '../admin-routing.module'; - -const GROUP_EDIT_PATH = 'groups'; - -export function getGroupEditPath(id: string) { - return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString(); -} +import { GROUP_EDIT_PATH } from './admin-access-control-routing-paths'; @NgModule({ imports: [ @@ -20,12 +13,12 @@ export function getGroupEditPath(id: string) { { path: `${GROUP_EDIT_PATH}/:groupId`, component: GroupFormComponent, - data: {title: 'admin.registries.schema.title'} + data: {title: 'admin.access-control.groups.title.singleGroup'} }, { path: `${GROUP_EDIT_PATH}/newGroup`, component: GroupFormComponent, - data: {title: 'admin.registries.schema.title'} + data: {title: 'admin.access-control.groups.title.addGroup'} }, ]) ] diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 20593756c14..f478557e003 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -4,90 +4,91 @@ - -
- -
- - -
-
- +
+
+
-
-
- - - - + + + +
+
-
- +
+
+ + + + +
+
+ - + -
- - - - - - - - - - - - - - - - - -
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} -
- - -
-
-
+
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{epersonDto.eperson.name}}{{epersonDto.eperson.email}} +
+ + +
+
+
-
+
- -
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index 17d8655bdd1..60f9933fb39 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -8,7 +8,7 @@ import { BrowserModule, By } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs/internal/Observable'; -import { PaginatedList } from '../../../core/data/paginated-list'; +import { PaginatedList, buildPaginatedList } 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'; @@ -24,6 +24,8 @@ import { getMockTranslateService } from '../../../shared/mocks/translate.service import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { RouterStub } from '../../../shared/testing/router.stub'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { RequestService } from '../../../core/data/request.service'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; @@ -33,6 +35,8 @@ describe('EPeopleRegistryComponent', () => { let mockEPeople; let ePersonDataServiceStub: any; + let authorizationService: AuthorizationDataService; + let modalService; beforeEach(async(() => { mockEPeople = [EPersonMock, EPersonMock2]; @@ -40,7 +44,7 @@ describe('EPeopleRegistryComponent', () => { activeEPerson: null, allEpeople: mockEPeople, getEPeople(): Observable>> { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); }, getActiveEPerson(): Observable { return observableOf(this.activeEPerson); @@ -50,18 +54,18 @@ describe('EPeopleRegistryComponent', () => { const result = this.allEpeople.find((ePerson: EPerson) => { return ePerson.email === query }); - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result])); } if (scope === 'metadata') { if (query === '') { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); } const result = this.allEpeople.find((ePerson: EPerson) => { return (ePerson.name.includes(query) || ePerson.email.includes(query)) }); - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result])); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result])); } - return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople)); }, deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { @@ -82,6 +86,9 @@ describe('EPeopleRegistryComponent', () => { return '/admin/access-control/epeople'; } }; + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); TestBed.configureTestingModule({ @@ -94,11 +101,13 @@ describe('EPeopleRegistryComponent', () => { }), ], declarations: [EPeopleRegistryComponent], - providers: [EPeopleRegistryComponent, + providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: AuthorizationDataService, useValue: authorizationService }, { provide: FormBuilderService, useValue: builderService }, { provide: Router, useValue: new RouterStub() }, + { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -107,12 +116,14 @@ describe('EPeopleRegistryComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; + modalService = (component as any).modalService; + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); - it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => { - expect(comp).toBeDefined(); - })); + it('should create EPeopleRegistryComponent', () => { + expect(component).toBeDefined(); + }); it('should display list of ePeople', () => { const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); @@ -175,7 +186,7 @@ describe('EPeopleRegistryComponent', () => { it('editEPerson form is toggled', () => { const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - if (activeEPerson === ePeopleIds[0].nativeElement.textContent) { + if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) { expect(component.isEPersonFormShown).toEqual(false); } else { expect(component.isEPersonFormShown).toEqual(true); @@ -183,6 +194,10 @@ describe('EPeopleRegistryComponent', () => { }) }); + + it('EPerson search section is hidden', () => { + expect(fixture.debugElement.query(By.css('#search'))).toBeNull(); + }); }); }); @@ -211,4 +226,20 @@ describe('EPeopleRegistryComponent', () => { }); }); + describe('delete EPerson button when the isAuthorized returns false', () => { + let ePeopleDeleteButton; + beforeEach(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false) + }); + }); + + it ('should be disabled', () => { + ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); + ePeopleDeleteButton.forEach((deleteButton) => { + expect(deleteButton.nativeElement.disabled).toBe(true); + }); + + }) + }) }); diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts index e88ba844187..8122e8483d0 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -2,16 +2,29 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { Subscription } from 'rxjs/internal/Subscription'; -import { map, take } from 'rxjs/operators'; -import { PaginatedList } from '../../../core/data/paginated-list'; +import { map, switchMap, take } from 'rxjs/operators'; +import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData +} from '../../../core/shared/operators'; +import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { RequestService } from '../../../core/data/request.service'; +import { filter } from 'rxjs/internal/operators/filter'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { NoContent } from '../../../core/shared/NoContent.model'; @Component({ selector: 'ds-epeople-registry', @@ -28,7 +41,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { /** * A list of all the current EPeople within the repository or the result of the search */ - ePeople: Observable>>; + ePeople$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + /** + * A BehaviorSubject with the list of EpersonDtoModel objects made from the EPeople in the repository or + * as the result of the search + */ + ePeopleDto$: BehaviorSubject> = new BehaviorSubject>({} as any); + + /** + * An observable for the pageInfo, needed to pass to the pagination component + */ + pageInfoState$: BehaviorSubject = new BehaviorSubject(undefined); /** * Pagination config used to display the list of epeople @@ -59,8 +82,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { constructor(private epersonService: EPersonDataService, private translateService: TranslateService, private notificationsService: NotificationsService, + private authorizationService: AuthorizationDataService, private formBuilder: FormBuilder, - private router: Router) { + private router: Router, + private modalService: NgbModal, + public requestService: RequestService) { this.currentSearchQuery = ''; this.currentSearchScope = 'metadata'; this.searchForm = this.formBuilder.group(({ @@ -70,6 +96,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { } ngOnInit() { + this.initialisePage(); + } + + /** + * This method will initialise the page + */ + initialisePage() { this.isEPersonFormShown = false; this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { @@ -84,18 +117,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { * @param event */ onPageChange(event) { - this.config.currentPage = event; - this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }) - } - - /** - * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing - * a new REST call - */ - public forceUpdateEPeople() { - this.epersonService.clearEPersonRequests(); - this.isEPersonFormShown = false; - this.search({ query: '', scope: 'metadata' }) + if (this.config.currentPage !== event) { + this.config.currentPage = event; + this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }) + } } /** @@ -115,10 +140,34 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.currentSearchScope = scope; this.config.currentPage = 1; } - this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + this.subs.push(this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { currentPage: this.config.currentPage, elementsPerPage: this.config.pageSize - }); + }).subscribe((peopleRD) => { + this.ePeople$.next(peopleRD); + this.pageInfoState$.next(peopleRD.payload.pageInfo); + } + )); + + this.subs.push(this.ePeople$.pipe( + getAllSucceededRemoteDataPayload(), + switchMap((epeople) => { + return combineLatest(...epeople.page.map((eperson) => { + return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( + map((authorized) => { + const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); + epersonDtoModel.ableToDelete = authorized; + epersonDtoModel.eperson = eperson; + return epersonDtoModel; + }) + ); + })).pipe(map((dtos: EpersonDtoModel[]) => { + return buildPaginatedList(epeople.pageInfo, dtos); + })) + })).subscribe((value) => { + this.ePeopleDto$.next(value); + this.pageInfoState$.next(value.pageInfo); + })); } /** @@ -160,16 +209,25 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ deleteEPerson(ePerson: EPerson) { if (hasValue(ePerson.id)) { - this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { - if (success) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); - this.forceUpdateEPeople(); - } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); - } - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }) + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = ePerson; + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; + modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { + if (confirm) { + if (hasValue(ePerson.id)) { + this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { + if (restResponse.hasSucceeded) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); + this.reset(); + } else { + this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); + } + }) + }} + }); } } @@ -177,6 +235,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { * Unsub all subscriptions */ ngOnDestroy(): void { + this.cleanupSubscribes(); + } + + cleanupSubscribes() { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } @@ -199,4 +261,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { }); this.search({ query: '' }); } + + /** + * This method will ensure that the page gets reset and that the cache is cleared + */ + reset() { + this.epersonService.getBrowseEndpoint().pipe( + switchMap((href) => this.requestService.removeByHrefSubstring(href)), + filter((isCached) => isCached), + take(1) + ).subscribe(() => { + this.cleanupSubscribes(); + this.initialisePage(); + }); + } } diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html index b87b3e08482..3f744240e5c 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -17,10 +17,10 @@

{{messagePrefix + '.edit' | translate}}

- - + - - + +
- {{messagePrefix + '.search.head' | tr - - {{messagePrefix + '.search.head' | tr -

{{messagePrefix + '.headSubgroups' | translate}}

- {{messagePrefix + '.headSubgroups' | translate}} - diff --git a/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts b/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts index 68942289ffa..1add6e3aa3a 100644 --- a/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts +++ b/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts @@ -8,7 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs/internal/Observable'; import { RestResponse } from '../../../../../core/cache/response.models'; -import { PaginatedList } from '../../../../../core/data/paginated-list'; +import { PaginatedList, buildPaginatedList } from '../../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../../core/data/remote-data'; import { GroupDataService } from '../../../../../core/eperson/group-data.service'; import { Group } from '../../../../../core/eperson/models/group.model'; @@ -52,16 +52,16 @@ describe('SubgroupsListComponent', () => { return this.activeGroup; }, findAllByHref(href: string): Observable>> { - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this.subgroups)) + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.subgroups)) }, getGroupEditPageRouterLink(group: Group): string { return '/admin/access-control/groups/' + group.id; }, searchGroups(query: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allGroups)) + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allGroups)) } - return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])) + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])) }, addSubGroupToGroup(parentGroup, subgroup: Group): Observable { this.subgroups = [...this.subgroups, subgroup]; diff --git a/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts b/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts index 62927b74aae..96177c5a11a 100644 --- a/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts +++ b/src/app/+admin/admin-access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts @@ -4,12 +4,15 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of as observableOf, Subscription } from 'rxjs'; import { map, mergeMap, take } from 'rxjs/operators'; -import { RestResponse } from '../../../../../core/cache/response.models'; -import { PaginatedList } from '../../../../../core/data/paginated-list'; +import { PaginatedList } from '../../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../../core/data/remote-data'; import { GroupDataService } from '../../../../../core/eperson/group-data.service'; import { Group } from '../../../../../core/eperson/models/group.model'; -import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators'; +import { + getRemoteDataPayload, + getFirstSucceededRemoteData, + getFirstCompletedRemoteData +} from '../../../../../core/shared/operators'; import { hasValue } from '../../../../../shared/empty.util'; import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model'; @@ -125,7 +128,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { elementsPerPage: Number.MAX_SAFE_INTEGER }) .pipe( - getSucceededRemoteData(), + getFirstSucceededRemoteData(), getRemoteDataPayload(), map((listTotalGroups: PaginatedList) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)), map((groups: Group[]) => groups.length > 0)) @@ -231,9 +234,9 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { * @param nameObject Object request was about * @param activeGroup Group currently being edited */ - showNotifications(messageSuffix: string, response: Observable, nameObject: string, activeGroup: Group) { - response.pipe(take(1)).subscribe((restResponse: RestResponse) => { - if (restResponse.isSuccessful) { + showNotifications(messageSuffix: string, response: Observable>, nameObject: string, activeGroup: Group) { + response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); diff --git a/src/app/+admin/admin-access-control/group-registry/groups-registry.component.html b/src/app/+admin/admin-access-control/group-registry/groups-registry.component.html index 3bd7d7ac4ff..2b0c35ea83c 100644 --- a/src/app/+admin/admin-access-control/group-registry/groups-registry.component.html +++ b/src/app/+admin/admin-access-control/group-registry/groups-registry.component.html @@ -30,10 +30,10 @@