mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge branch 'main' into w2p-85192_pr-bugfix-specify-view-mode-in-ds-browse-by
This commit is contained in:
61
.github/workflows/build.yml
vendored
61
.github/workflows/build.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
|||||||
DSPACE_REST_PORT: 8080
|
DSPACE_REST_PORT: 8080
|
||||||
DSPACE_REST_NAMESPACE: '/server'
|
DSPACE_REST_NAMESPACE: '/server'
|
||||||
DSPACE_REST_SSL: false
|
DSPACE_REST_SSL: false
|
||||||
# When Chrome version is specified, we pin to a specific version of Chrome & ChromeDriver
|
# When Chrome version is specified, we pin to a specific version of Chrome
|
||||||
# Comment this out to use the latest release of both.
|
# Comment this out to use the latest release
|
||||||
CHROME_VERSION: "90.0.4430.212-1"
|
#CHROME_VERSION: "90.0.4430.212-1"
|
||||||
strategy:
|
strategy:
|
||||||
# Create a matrix of Node versions to test against (in parallel)
|
# Create a matrix of Node versions to test against (in parallel)
|
||||||
matrix:
|
matrix:
|
||||||
@@ -66,12 +66,6 @@ jobs:
|
|||||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-yarn-
|
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
|
- name: Install Yarn dependencies
|
||||||
run: yarn install --frozen-lockfile
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
@@ -99,23 +93,40 @@ jobs:
|
|||||||
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
|
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
|
||||||
docker container ls
|
docker container ls
|
||||||
|
|
||||||
# Wait until the REST API returns a 200 response (or for a max of 30 seconds)
|
# Run integration tests via Cypress.io
|
||||||
# https://github.com/nev7n/wait_for_response
|
# https://github.com/cypress-io/github-action
|
||||||
- name: Wait for DSpace REST Backend to be ready (for e2e tests)
|
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
||||||
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
|
|
||||||
|
|
||||||
- name: Run e2e tests (integration tests)
|
- name: Run e2e tests (integration tests)
|
||||||
run: |
|
uses: cypress-io/github-action@v2
|
||||||
chromedriver --url-base='/wd/hub' --port=4444 &
|
with:
|
||||||
yarn run e2e:ci
|
# 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
|
||||||
|
|
||||||
# Start up the app with SSR enabled (run in background)
|
# Start up the app with SSR enabled (run in background)
|
||||||
- name: Start app in SSR (server-side rendering) mode
|
- name: Start app in SSR (server-side rendering) mode
|
||||||
|
163
README.md
163
README.md
@@ -61,14 +61,17 @@ Table of Contents
|
|||||||
- [Introduction to the technology](#introduction-to-the-technology)
|
- [Introduction to the technology](#introduction-to-the-technology)
|
||||||
- [Requirements](#requirements)
|
- [Requirements](#requirements)
|
||||||
- [Installing](#installing)
|
- [Installing](#installing)
|
||||||
- [Configuring](#configuring)
|
- [Configuring](#configuring)
|
||||||
- [Running the app](#running-the-app)
|
- [Running the app](#running-the-app)
|
||||||
- [Running in production mode](#running-in-production-mode)
|
- [Running in production mode](#running-in-production-mode)
|
||||||
- [Deploy](#deploy)
|
- [Deploy](#deploy)
|
||||||
- [Running the application with Docker](#running-the-application-with-docker)
|
- [Running the application with Docker](#running-the-application-with-docker)
|
||||||
- [Cleaning](#cleaning)
|
- [Cleaning](#cleaning)
|
||||||
- [Testing](#testing)
|
- [Testing](#testing)
|
||||||
- [Test a Pull Request](#test-a-pull-request)
|
- [Test a Pull Request](#test-a-pull-request)
|
||||||
|
- [Unit Tests](#unit-tests)
|
||||||
|
- [E2E Tests](#e2e-tests)
|
||||||
|
- [Writing E2E Tests](#writing-e2e-tests)
|
||||||
- [Documentation](#documentation)
|
- [Documentation](#documentation)
|
||||||
- [Other commands](#other-commands)
|
- [Other commands](#other-commands)
|
||||||
- [Recommended Editors/IDEs](#recommended-editorsides)
|
- [Recommended Editors/IDEs](#recommended-editorsides)
|
||||||
@@ -104,9 +107,9 @@ Default configuration file is located in `src/environments/` folder.
|
|||||||
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 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.ts` file in `src/environments/` for a `development` environment;
|
- 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 `environment.prod.ts` file in `src/environments/` for a `production` environment;
|
||||||
|
|
||||||
The server settings can also be overwritten using an environment file.
|
The server settings can also be overwritten using an environment file.
|
||||||
|
|
||||||
This file should be called `.env` and be placed in the project root.
|
This file should be called `.env` and be placed in the project root.
|
||||||
|
|
||||||
@@ -125,7 +128,7 @@ 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.:
|
The same settings can also be overwritten by setting system environment variables instead, E.g.:
|
||||||
```bash
|
```bash
|
||||||
export DSPACE_HOST=api7.dspace.org
|
export DSPACE_HOST=api7.dspace.org
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,7 +143,7 @@ To use environment variables in a UI component, use:
|
|||||||
import { environment } from '../environment.ts';
|
import { environment } from '../environment.ts';
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
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`
|
||||||
|
|
||||||
|
|
||||||
Running the app
|
Running the app
|
||||||
@@ -209,34 +212,66 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
|
|||||||
|
|
||||||
### Unit Tests
|
### 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.
|
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, you MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `environment.prod.ts` or `environment.common.ts`. You may override this using env variables, see [Configuring](#configuring).
|
||||||
|
|
||||||
and run: `ng e2e`
|
Run `ng e2e` to kick off the tests. This 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.
|
||||||
|
|
||||||
### Continuous Integration (CI) Test
|
#### Writing E2E Tests
|
||||||
|
|
||||||
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.
|
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
|
||||||
|
* 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.
|
||||||
|
* 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
|
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
|
### Building code documentation
|
||||||
|
|
||||||
@@ -259,8 +294,6 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've
|
|||||||
- Free
|
- Free
|
||||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||||
- [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome)
|
- [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
|
- Paid
|
||||||
- [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/)
|
- [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/)
|
||||||
- [Sublime Text](http://www.sublimetext.com/3)
|
- [Sublime Text](http://www.sublimetext.com/3)
|
||||||
@@ -282,95 +315,43 @@ dspace-angular
|
|||||||
│ ├── environment.default.js * Default configuration files
|
│ ├── environment.default.js * Default configuration files
|
||||||
│ └── environment.test.js * Test configuration files
|
│ └── environment.test.js * Test configuration files
|
||||||
├── docs * Folder for documentation
|
├── docs * Folder for documentation
|
||||||
├── e2e * Folder for e2e test files
|
├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests
|
||||||
│ ├── app.e2e-spec.ts *
|
│ ├── integration * Folder for e2e/integration test files
|
||||||
│ ├── app.po.ts *
|
│ ├── fixtures * Folder for any fixtures needed by e2e tests
|
||||||
│ ├── pagenotfound *
|
│ ├── plugins * Folder for Cypress plugins (if any)
|
||||||
│ │ ├── pagenotfound.e2e-spec.ts *
|
│ ├── support * Folder for global e2e test actions/commands (run for all tests)
|
||||||
│ │ └── pagenotfound.po.ts *
|
|
||||||
│ └── tsconfig.json * TypeScript configuration file for e2e tests
|
│ └── tsconfig.json * TypeScript configuration file for e2e tests
|
||||||
├── karma.conf.js * Karma configuration file for Unit Test
|
├── karma.conf.js * Karma configuration file for Unit Test
|
||||||
├── nodemon.json * Nodemon (https://nodemon.io/) configuration
|
├── nodemon.json * Nodemon (https://nodemon.io/) configuration
|
||||||
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
|
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
|
||||||
├── postcss.config.js * PostCSS (http://postcss.org/) configuration file
|
├── 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
|
├── src * The source of the application
|
||||||
│ ├── app *
|
│ ├── app * The source code of the application, subdivided by module/page.
|
||||||
│ │ ├── app-routing.module.ts *
|
│ ├── assets * Folder for static resources
|
||||||
│ │ ├── app.component.html *
|
│ │ ├── fonts * Folder for fonts
|
||||||
│ │ ├── app.component.scss *
|
│ │ ├── i18n * Folder for i18n translations
|
||||||
│ │ ├── app.component.spec.ts *
|
│ | └── en.json5 * i18n translations for English
|
||||||
│ │ ├── app.component.ts *
|
│ │ └── images * Folder for images
|
||||||
│ │ ├── 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
|
│ ├── backend * Folder containing a mock of the REST API, hosted by the express server
|
||||||
│ │ ├── api.ts *
|
|
||||||
│ │ ├── cache.ts *
|
|
||||||
│ │ ├── data *
|
|
||||||
│ │ └── db.ts *
|
|
||||||
│ ├── config *
|
│ ├── 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.csr.html * The index file for client side rendering fallback
|
||||||
│ ├── index.html * The index file
|
│ ├── index.html * The index file
|
||||||
│ ├── main.browser.ts * The bootstrap file for the client
|
│ ├── main.browser.ts * The bootstrap file for the client
|
||||||
│ ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
|
│ ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
|
||||||
|
│ ├── robots.txt * The robots.txt file
|
||||||
│ ├── modules *
|
│ ├── 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
|
│ ├── styles * Folder containing global styles
|
||||||
│ │ ├── _mixins.scss *
|
│ └── themes * Folder containing available themes
|
||||||
│ │ └── variables.scss * Global sass variables file
|
│ ├── custom * Template folder for creating a custom theme
|
||||||
│ ├── tsconfig.browser.json * TypeScript config for the client build
|
│ └── dspace * Default 'dspace' theme
|
||||||
│ ├── tsconfig.server.json * TypeScript config for the server build
|
|
||||||
│ └── tsconfig.test.json * TypeScript config for the test build
|
|
||||||
├── tsconfig.json * TypeScript config
|
├── tsconfig.json * TypeScript config
|
||||||
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
|
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
|
||||||
├── typedoc.json * TYPEDOC configuration
|
├── typedoc.json * TYPEDOC configuration
|
||||||
├── webpack * Webpack (https://webpack.github.io/) config directory
|
├── webpack * Webpack (https://webpack.github.io/) config directory
|
||||||
│ ├── webpack.aot.js * Webpack (https://webpack.github.io/) config for AoT build
|
│ ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for client build
|
||||||
│ ├── webpack.client.js * Webpack (https://webpack.github.io/) config for client build
|
│ ├── webpack.common.ts *
|
||||||
│ ├── webpack.common.js *
|
│ ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for production build
|
||||||
│ ├── webpack.prod.js * Webpack (https://webpack.github.io/) config for production build
|
│ └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test 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)
|
└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
41
angular.json
41
angular.json
@@ -147,7 +147,7 @@
|
|||||||
"tsConfig": [
|
"tsConfig": [
|
||||||
"tsconfig.app.json",
|
"tsconfig.app.json",
|
||||||
"tsconfig.spec.json",
|
"tsconfig.spec.json",
|
||||||
"e2e/tsconfig.json"
|
"cypress/tsconfig.json"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"**/node_modules/**"
|
"**/node_modules/**"
|
||||||
@@ -155,10 +155,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"e2e": {
|
"e2e": {
|
||||||
"builder": "@angular-devkit/build-angular:protractor",
|
"builder": "@cypress/schematic:cypress",
|
||||||
"options": {
|
"options": {
|
||||||
"protractorConfig": "e2e/protractor.conf.js",
|
"devServerTarget": "dspace-angular:serve",
|
||||||
"devServerTarget": "dspace-angular:serve"
|
"watch": true,
|
||||||
|
"headless": false
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -176,16 +177,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outputPath": "dist/server",
|
"outputPath": "dist/server",
|
||||||
"main": "src/main.server.ts",
|
"main": "server.ts",
|
||||||
"tsConfig": "tsconfig.server.json"
|
"tsConfig": "tsconfig.server.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"optimization": {
|
"optimization": true
|
||||||
"scripts": false,
|
|
||||||
"styles": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -215,9 +213,30 @@
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {}
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultProject": "dspace-angular"
|
"defaultProject": "dspace-angular",
|
||||||
}
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
9
cypress.json
Normal file
9
cypress.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal 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"
|
||||||
|
}
|
15
cypress/integration/breadcrumbs.spec.ts
Normal file
15
cypress/integration/breadcrumbs.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/browse-by-author.spec.ts
Normal file
13
cypress/integration/browse-by-author.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/browse-by-dateissued.spec.ts
Normal file
13
cypress/integration/browse-by-dateissued.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/browse-by-subject.spec.ts
Normal file
13
cypress/integration/browse-by-subject.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/browse-by-title.spec.ts
Normal file
13
cypress/integration/browse-by-title.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
15
cypress/integration/collection-page.spec.ts
Normal file
15
cypress/integration/collection-page.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
32
cypress/integration/collection-statistics.spec.ts
Normal file
32
cypress/integration/collection-statistics.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
25
cypress/integration/community-list.spec.ts
Normal file
25
cypress/integration/community-list.spec.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
15
cypress/integration/community-page.spec.ts
Normal file
15
cypress/integration/community-page.spec.ts
Normal 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',);
|
||||||
|
});
|
||||||
|
});
|
32
cypress/integration/community-statistics.spec.ts
Normal file
32
cypress/integration/community-statistics.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/footer.spec.ts
Normal file
13
cypress/integration/footer.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
19
cypress/integration/header.spec.ts
Normal file
19
cypress/integration/header.spec.ts
Normal 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
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
19
cypress/integration/homepage-statistics.spec.ts
Normal file
19
cypress/integration/homepage-statistics.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
32
cypress/integration/homepage.spec.ts
Normal file
32
cypress/integration/homepage.spec.ts
Normal 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('ds-search-form input[name="query"]').type(queryString);
|
||||||
|
cy.get('ds-search-form button.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');
|
||||||
|
});
|
||||||
|
});
|
31
cypress/integration/item-page.spec.ts
Normal file
31
cypress/integration/item-page.spec.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
38
cypress/integration/item-statistics.spec.ts
Normal file
38
cypress/integration/item-statistics.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
13
cypress/integration/pagenotfound.spec.ts
Normal file
13
cypress/integration/pagenotfound.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
49
cypress/integration/search-navbar.spec.ts
Normal file
49
cypress/integration/search-navbar.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const page = {
|
||||||
|
fillOutQueryInNavBar(query) {
|
||||||
|
// Click the magnifying glass
|
||||||
|
cy.get('.navbar-container #search-navbar-container form a').click();
|
||||||
|
// Fill out a query in input that appears
|
||||||
|
cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type(query);
|
||||||
|
},
|
||||||
|
submitQueryByPressingEnter() {
|
||||||
|
cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type('{enter}');
|
||||||
|
},
|
||||||
|
submitQueryByPressingIcon() {
|
||||||
|
cy.get('.navbar-container #search-navbar-container form .submit-icon').click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Search from Navigation Bar', () => {
|
||||||
|
// NOTE: these tests currently assume this query will return results!
|
||||||
|
const query = 'test';
|
||||||
|
|
||||||
|
it('should go to search page with correct query if submitted (from home)', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
page.fillOutQueryInNavBar(query);
|
||||||
|
page.submitQueryByPressingEnter();
|
||||||
|
// New URL should include query param
|
||||||
|
cy.url().should('include', 'query=' + query);
|
||||||
|
// At least one search result should be displayed
|
||||||
|
cy.get('ds-item-search-result-list-element').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to search page with correct query if submitted (from search)', () => {
|
||||||
|
cy.visit('/search');
|
||||||
|
page.fillOutQueryInNavBar(query);
|
||||||
|
page.submitQueryByPressingEnter();
|
||||||
|
// New URL should include query param
|
||||||
|
cy.url().should('include', 'query=' + query);
|
||||||
|
// At least one search result should be displayed
|
||||||
|
cy.get('ds-item-search-result-list-element').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow user to also submit query by clicking icon', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
page.fillOutQueryInNavBar(query);
|
||||||
|
page.submitQueryByPressingIcon();
|
||||||
|
// New URL should include query param
|
||||||
|
cy.url().should('include', 'query=' + query);
|
||||||
|
// At least one search result should be displayed
|
||||||
|
cy.get('ds-item-search-result-list-element').should('be.visible');
|
||||||
|
});
|
||||||
|
});
|
72
cypress/integration/search-page.spec.ts
Normal file
72
cypress/integration/search-page.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Options } from 'cypress-axe';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Search Page', () => {
|
||||||
|
// unique ID of the search form (for selecting specific elements below)
|
||||||
|
const SEARCHFORM_ID = '#search-form';
|
||||||
|
|
||||||
|
it('should contain query value when navigating to page with query parameter', () => {
|
||||||
|
const queryString = 'test query';
|
||||||
|
cy.visit('/search?query=' + queryString);
|
||||||
|
cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(SEARCHFORM_ID + ' input[name="query"]').type(queryString);
|
||||||
|
cy.get(SEARCHFORM_ID + ' button.search-button').click();
|
||||||
|
cy.url().should('include', 'query=' + encodeURI(queryString));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit('/search');
|
||||||
|
|
||||||
|
// <ds-search-page> tag must be loaded
|
||||||
|
cy.get('ds-search-page').should('exist');
|
||||||
|
|
||||||
|
// 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-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 pass accessibility tests in Grid view', () => {
|
||||||
|
cy.visit('/search');
|
||||||
|
|
||||||
|
// Click to display grid view
|
||||||
|
// TODO: These buttons should likely have an easier way to uniquely select
|
||||||
|
cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?spc.sf=score&spc.sd=DESC&view=grid"] > .fas').click();
|
||||||
|
|
||||||
|
// <ds-search-page> tag must be loaded
|
||||||
|
cy.get('ds-search-page').should('exist');
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
16
cypress/plugins/index.ts
Normal file
16
cypress/plugins/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// 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) => {
|
||||||
|
// 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
|
||||||
|
on('task', {
|
||||||
|
log(message: string) {
|
||||||
|
console.log(message);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
table(message: string) {
|
||||||
|
console.table(message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
43
cypress/support/commands.ts
Normal file
43
cypress/support/commands.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ***********************************************
|
||||||
|
// This example namespace declaration will help
|
||||||
|
// with Intellisense and code completion in your
|
||||||
|
// IDE or Text Editor.
|
||||||
|
// ***********************************************
|
||||||
|
// declare namespace Cypress {
|
||||||
|
// interface Chainable<Subject = any> {
|
||||||
|
// customCommand(param: any): typeof customCommand;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// function customCommand(param: any): void {
|
||||||
|
// console.warn(param);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// NOTE: You can use it like so:
|
||||||
|
// Cypress.Commands.add('customCommand', customCommand);
|
||||||
|
//
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add("login", (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
26
cypress/support/index.ts
Normal file
26
cypress/support/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// 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
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// When a command from ./commands is ready to use, import with `import './commands'` syntax
|
||||||
|
// import './commands';
|
||||||
|
|
||||||
|
// Import Cypress Axe tools for all tests
|
||||||
|
// https://github.com/component-driven/cypress-axe
|
||||||
|
import 'cypress-axe';
|
||||||
|
|
||||||
|
// Global constants used in tests
|
||||||
|
export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200';
|
||||||
|
export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4';
|
||||||
|
export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
44
cypress/support/utils.ts
Normal file
44
cypress/support/utils.ts
Normal 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
13
cypress/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"cypress",
|
||||||
|
"cypress-axe",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@@ -81,15 +81,22 @@ services:
|
|||||||
# Keep Solr data directory between reboots
|
# Keep Solr data directory between reboots
|
||||||
- solr_data:/var/solr/data
|
- solr_data:/var/solr/data
|
||||||
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
|
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
|
||||||
|
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
|
||||||
|
# * 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:
|
entrypoint:
|
||||||
- /bin/bash
|
- /bin/bash
|
||||||
- '-c'
|
- '-c'
|
||||||
- |
|
- |
|
||||||
init-var-solr
|
init-var-solr
|
||||||
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
||||||
|
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
|
||||||
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
|
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
|
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
|
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
|
||||||
|
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics
|
||||||
exec solr -f
|
exec solr -f
|
||||||
volumes:
|
volumes:
|
||||||
assetstore:
|
assetstore:
|
||||||
|
@@ -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;
|
|
@@ -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'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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());
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
53
package.json
53
package.json
@@ -8,6 +8,8 @@
|
|||||||
"config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts",
|
"config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts",
|
||||||
"config:test:watch": "nodemon --config mock-nodemon.json",
|
"config:test:watch": "nodemon --config mock-nodemon.json",
|
||||||
"config:dev:watch": "nodemon",
|
"config:dev:watch": "nodemon",
|
||||||
|
"config:check:rest": "yarn run config:prod && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
|
||||||
|
"config:dev:check:rest": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
|
||||||
"prestart:dev": "yarn run config:dev",
|
"prestart:dev": "yarn run config:dev",
|
||||||
"prebuild": "yarn run config:dev",
|
"prebuild": "yarn run config:dev",
|
||||||
"pretest": "yarn run config:test",
|
"pretest": "yarn run config:test",
|
||||||
@@ -15,26 +17,23 @@
|
|||||||
"pretest:headless": "yarn run config:test",
|
"pretest:headless": "yarn run config:test",
|
||||||
"prebuild:prod": "yarn run config:prod",
|
"prebuild:prod": "yarn run config:prod",
|
||||||
"pree2e": "yarn run config:prod",
|
"pree2e": "yarn run config:prod",
|
||||||
"pree2e:ci": "yarn run config:prod",
|
|
||||||
"start": "yarn run start:prod",
|
"start": "yarn run start:prod",
|
||||||
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
|
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
|
||||||
"start:dev": "npm-run-all --parallel config:dev:watch serve",
|
"start:dev": "npm-run-all --parallel config:dev:watch serve",
|
||||||
"start:prod": "yarn run build:prod && yarn run serve:ssr",
|
"start:prod": "yarn run build:prod && yarn run serve:ssr",
|
||||||
|
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
|
||||||
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
|
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"build:stats": "ng build --stats-json",
|
"build:stats": "ng build --stats-json",
|
||||||
"build:prod": "yarn run build:ssr",
|
"build:prod": "yarn run build:ssr",
|
||||||
"build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server",
|
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
|
||||||
"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:watch": "npm-run-all --parallel config:test:watch test",
|
||||||
"test": "ng test --sourceMap=true --watch=true",
|
"test": "ng test --sourceMap=true --watch=true",
|
||||||
"test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage",
|
"test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint-fix": "ng lint --fix=true",
|
"lint-fix": "ng lint --fix=true",
|
||||||
"e2e": "ng e2e",
|
"e2e": "ng e2e",
|
||||||
"e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js",
|
"serve:ssr": "node dist/server/main",
|
||||||
"compile:server": "webpack --config webpack.server.config.js --progress --color",
|
|
||||||
"serve:ssr": "node dist/server",
|
|
||||||
"clean:coverage": "rimraf coverage",
|
"clean:coverage": "rimraf coverage",
|
||||||
"clean:dist": "rimraf dist",
|
"clean:dist": "rimraf dist",
|
||||||
"clean:doc": "rimraf doc",
|
"clean:doc": "rimraf doc",
|
||||||
@@ -46,7 +45,11 @@
|
|||||||
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
|
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
|
||||||
"clean:env": "rimraf src/environments/environment.ts",
|
"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",
|
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
|
||||||
"postinstall": "ngcc"
|
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
|
||||||
|
"merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
|
||||||
|
"postinstall": "ngcc",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:run": "cypress run"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"fs": false,
|
"fs": false,
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
"@angular/platform-server": "~10.2.3",
|
"@angular/platform-server": "~10.2.3",
|
||||||
"@angular/router": "~10.2.3",
|
"@angular/router": "~10.2.3",
|
||||||
"@angularclass/bootloader": "1.0.1",
|
"@angularclass/bootloader": "1.0.1",
|
||||||
|
"@kolkov/ngx-gallery": "^1.2.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "7.0.0",
|
"@ng-bootstrap/ng-bootstrap": "7.0.0",
|
||||||
"@ng-dynamic-forms/core": "^12.0.0",
|
"@ng-dynamic-forms/core": "^12.0.0",
|
||||||
"@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0",
|
"@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0",
|
||||||
@@ -88,9 +92,9 @@
|
|||||||
"caniuse-lite": "^1.0.30001165",
|
"caniuse-lite": "^1.0.30001165",
|
||||||
"cerialize": "0.1.18",
|
"cerialize": "0.1.18",
|
||||||
"cli-progress": "^3.8.0",
|
"cli-progress": "^3.8.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "1.4.5",
|
"cookie-parser": "1.4.5",
|
||||||
"core-js": "^3.7.0",
|
"core-js": "^3.7.0",
|
||||||
"debug-loader": "^0.0.1",
|
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.2.2",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-rate-limit": "^5.1.3",
|
"express-rate-limit": "^5.1.3",
|
||||||
@@ -99,16 +103,21 @@
|
|||||||
"filesize": "^6.1.0",
|
"filesize": "^6.1.0",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
|
"http-proxy-middleware": "^1.0.5",
|
||||||
"js-cookie": "2.2.1",
|
"js-cookie": "2.2.1",
|
||||||
"json5": "^2.1.3",
|
"json5": "^2.1.3",
|
||||||
"jsonschema": "1.4.0",
|
"jsonschema": "1.4.0",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"klaro": "^0.7.10",
|
"klaro": "^0.7.10",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mirador": "^3.0.0",
|
||||||
|
"mirador-dl-plugin": "^0.13.0",
|
||||||
|
"mirador-share-plugin": "^0.10.0",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"ng-mocks": "10.5.4",
|
"ng-mocks": "10.5.4",
|
||||||
"ng2-file-upload": "1.4.0",
|
"ng2-file-upload": "1.4.0",
|
||||||
"ng2-nouislider": "^1.8.2",
|
"ng2-nouislider": "^1.8.3",
|
||||||
"ngx-infinite-scroll": "^10.0.1",
|
"ngx-infinite-scroll": "^10.0.1",
|
||||||
"ngx-moment": "^5.0.0",
|
"ngx-moment": "^5.0.0",
|
||||||
"ngx-pagination": "5.0.0",
|
"ngx-pagination": "5.0.0",
|
||||||
@@ -116,25 +125,27 @@
|
|||||||
"nouislider": "^14.6.3",
|
"nouislider": "^14.6.3",
|
||||||
"pem": "1.14.4",
|
"pem": "1.14.4",
|
||||||
"postcss-cli": "^8.3.0",
|
"postcss-cli": "^8.3.0",
|
||||||
|
"react": "^16.14.0",
|
||||||
|
"react-dom": "^16.14.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^6.6.3",
|
"rxjs": "^6.6.3",
|
||||||
"rxjs-spy": "^7.5.3",
|
|
||||||
"sass-resources-loader": "^2.1.1",
|
|
||||||
"sortablejs": "1.13.0",
|
"sortablejs": "1.13.0",
|
||||||
"tslib": "^2.0.0",
|
"tslib": "^2.0.0",
|
||||||
|
"url-parse": "^1.5.3",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
"webfontloader": "1.6.28",
|
"webfontloader": "1.6.28",
|
||||||
"zone.js": "^0.10.3",
|
"zone.js": "^0.10.3"
|
||||||
"@kolkov/ngx-gallery": "^1.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "10.0.1",
|
"@angular-builders/custom-webpack": "10.0.1",
|
||||||
"@angular-devkit/build-angular": "~0.1002.0",
|
"@angular-devkit/build-angular": "~0.1002.3",
|
||||||
"@angular/cli": "~10.2.0",
|
"@angular/cli": "~10.2.0",
|
||||||
"@angular/compiler-cli": "~10.2.3",
|
"@angular/compiler-cli": "~10.2.3",
|
||||||
"@angular/language-service": "~10.2.3",
|
"@angular/language-service": "~10.2.3",
|
||||||
|
"@cypress/schematic": "^1.5.0",
|
||||||
"@fortawesome/fontawesome-free": "^5.5.0",
|
"@fortawesome/fontawesome-free": "^5.5.0",
|
||||||
"@ngrx/store-devtools": "^10.0.1",
|
"@ngrx/store-devtools": "^10.0.1",
|
||||||
"@ngtools/webpack": "10.2.0",
|
"@ngtools/webpack": "10.2.3",
|
||||||
"@nguniversal/builders": "~10.1.0",
|
"@nguniversal/builders": "~10.1.0",
|
||||||
"@types/deep-freeze": "0.1.2",
|
"@types/deep-freeze": "0.1.2",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
@@ -144,16 +155,20 @@
|
|||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
"@types/lodash": "^4.14.165",
|
"@types/lodash": "^4.14.165",
|
||||||
"@types/node": "^14.14.9",
|
"@types/node": "^14.14.9",
|
||||||
|
"axe-core": "^4.3.3",
|
||||||
"codelyzer": "^6.0.1",
|
"codelyzer": "^6.0.1",
|
||||||
"compression-webpack-plugin": "^3.0.1",
|
"compression-webpack-plugin": "^3.0.1",
|
||||||
"copy-webpack-plugin": "^6.4.1",
|
"copy-webpack-plugin": "^6.4.1",
|
||||||
"css-loader": "3.4.0",
|
"css-loader": "3.4.0",
|
||||||
"cssnano": "^4.1.10",
|
"cssnano": "^4.1.10",
|
||||||
|
"cypress": "8.6.0",
|
||||||
|
"cypress-axe": "^0.13.0",
|
||||||
|
"debug-loader": "^0.0.1",
|
||||||
"deep-freeze": "0.0.1",
|
"deep-freeze": "0.0.1",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.0.3",
|
"fork-ts-checker-webpack-plugin": "^6.0.3",
|
||||||
|
"html-loader": "^1.3.2",
|
||||||
"html-webpack-plugin": "^4.5.0",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
"http-proxy-middleware": "^1.0.5",
|
|
||||||
"jasmine-core": "^3.6.0",
|
"jasmine-core": "^3.6.0",
|
||||||
"jasmine-marbles": "0.6.0",
|
"jasmine-marbles": "0.6.0",
|
||||||
"jasmine-spec-reporter": "^6.0.0",
|
"jasmine-spec-reporter": "^6.0.0",
|
||||||
@@ -175,16 +190,18 @@
|
|||||||
"protractor-istanbul-plugin": "2.0.0",
|
"protractor-istanbul-plugin": "2.0.0",
|
||||||
"raw-loader": "0.5.1",
|
"raw-loader": "0.5.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
|
"rxjs-spy": "^7.5.3",
|
||||||
|
"sass-resources-loader": "^2.1.1",
|
||||||
"script-ext-html-webpack-plugin": "2.1.5",
|
"script-ext-html-webpack-plugin": "2.1.5",
|
||||||
"string-replace-loader": "^2.3.0",
|
"string-replace-loader": "^2.3.0",
|
||||||
"terser-webpack-plugin": "^2.3.1",
|
"terser-webpack-plugin": "^2.3.1",
|
||||||
"ts-loader": "^5.2.0",
|
"ts-loader": "^5.2.0",
|
||||||
"ts-node": "^8.8.1",
|
"ts-node": "^8.10.2",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
"typescript": "~4.0.5",
|
"typescript": "~4.0.5",
|
||||||
"webpack": "^4.44.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-bundle-analyzer": "^4.4.0",
|
"webpack-bundle-analyzer": "^4.4.0",
|
||||||
"webpack-cli": "^4.2.0",
|
"webpack-cli": "^4.2.0",
|
||||||
"webpack-node-externals": "1.7.2"
|
"webpack-dev-server": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
99
scripts/merge-i18n-files.ts
Normal file
99
scripts/merge-i18n-files.ts
Normal 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();
|
||||||
|
}
|
66
scripts/test-rest.ts
Normal file
66
scripts/test-rest.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
|
import { environment } from '../src/environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to test the connection with the configured REST API (in the 'rest' settings of your environment.*.ts)
|
||||||
|
*
|
||||||
|
* This script is useful to test for any Node.js connection issues with your REST API.
|
||||||
|
*
|
||||||
|
* Usage (see package.json): yarn test:rest-api
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get root URL of configured REST API
|
||||||
|
const restUrl = environment.rest.baseUrl + '/api';
|
||||||
|
console.log(`...Testing connection to REST API at ${restUrl}...\n`);
|
||||||
|
|
||||||
|
// If SSL enabled, test via HTTPS, else via HTTP
|
||||||
|
if (environment.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 === environment.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}`);
|
||||||
|
}
|
||||||
|
}
|
99
server.ts
99
server.ts
@@ -30,6 +30,7 @@ import { join } from 'path';
|
|||||||
|
|
||||||
import { enableProdMode } from '@angular/core';
|
import { enableProdMode } from '@angular/core';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
|
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||||
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
|
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
|
||||||
import { environment } from './src/environments/environment';
|
import { environment } from './src/environments/environment';
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
@@ -37,16 +38,17 @@ import { hasValue, hasNoValue } from './src/app/shared/empty.util';
|
|||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
import { UIServerConfig } from './src/config/ui-server-config.interface';
|
import { UIServerConfig } from './src/config/ui-server-config.interface';
|
||||||
|
|
||||||
|
import { ServerAppModule } from './src/main.server';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set path for the browser application's dist folder
|
* Set path for the browser application's dist folder
|
||||||
*/
|
*/
|
||||||
const DIST_FOLDER = join(process.cwd(), 'dist/browser');
|
const DIST_FOLDER = join(process.cwd(), 'dist/browser');
|
||||||
|
// Set path fir IIIF viewer.
|
||||||
|
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
|
||||||
|
|
||||||
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
|
const indexHtml = 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 cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
// The Express app is exported so that it can be used by serverless Functions.
|
// The Express app is exported so that it can be used by serverless Functions.
|
||||||
@@ -57,7 +59,6 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
const server = express();
|
const server = express();
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If production mode is enabled in the environment file:
|
* If production mode is enabled in the environment file:
|
||||||
* - Enable Angular's production mode
|
* - Enable Angular's production mode
|
||||||
@@ -135,6 +136,10 @@ export function app() {
|
|||||||
* Serve static resources (images, i18n messages, …)
|
* Serve static resources (images, i18n messages, …)
|
||||||
*/
|
*/
|
||||||
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
|
server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
|
||||||
|
/*
|
||||||
|
* 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
|
// Register the ngApp callback function to handle incoming requests
|
||||||
server.get('*', ngApp);
|
server.get('*', ngApp);
|
||||||
@@ -221,47 +226,59 @@ function run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
function start() {
|
||||||
* If SSL is enabled
|
/*
|
||||||
* - Read credentials from configuration files
|
* If SSL is enabled
|
||||||
* - Call script to start an HTTPS server with these credentials
|
* - Read credentials from configuration files
|
||||||
* When SSL is disabled
|
* - Call script to start an HTTPS server with these credentials
|
||||||
* - Start an HTTP server on the configured port and host
|
* When SSL is disabled
|
||||||
*/
|
* - Start an HTTP server on the configured port and host
|
||||||
if (environment.ui.ssl) {
|
*/
|
||||||
let serviceKey;
|
if (environment.ui.ssl) {
|
||||||
try {
|
let serviceKey;
|
||||||
serviceKey = fs.readFileSync('./config/ssl/key.pem');
|
try {
|
||||||
} catch (e) {
|
serviceKey = fs.readFileSync('./config/ssl/key.pem');
|
||||||
console.warn('Service key not found at ./config/ssl/key.pem');
|
} catch (e) {
|
||||||
}
|
console.warn('Service key not found at ./config/ssl/key.pem');
|
||||||
|
}
|
||||||
|
|
||||||
let certificate;
|
let certificate;
|
||||||
try {
|
try {
|
||||||
certificate = fs.readFileSync('./config/ssl/cert.pem');
|
certificate = fs.readFileSync('./config/ssl/cert.pem');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Certificate not found at ./config/ssl/key.pem');
|
console.warn('Certificate not found at ./config/ssl/key.pem');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serviceKey && certificate) {
|
if (serviceKey && certificate) {
|
||||||
createHttpsServer({
|
createHttpsServer({
|
||||||
serviceKey: serviceKey,
|
serviceKey: serviceKey,
|
||||||
certificate: certificate
|
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 {
|
} 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.');
|
run();
|
||||||
|
|
||||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation]
|
|
||||||
|
|
||||||
pem.createCertificate({
|
|
||||||
days: 1,
|
|
||||||
selfSigned: true
|
|
||||||
}, (error, keys) => {
|
|
||||||
createHttpsServer(keys);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} 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';
|
export * from './src/main.server';
|
||||||
|
@@ -52,15 +52,17 @@
|
|||||||
<table id="groups" class="table table-striped table-hover table-bordered">
|
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + '.table.name' | 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||||
<td>{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
|
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||||
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||||
|
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
|||||||
import { RequestService } from '../../../core/data/request.service';
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
||||||
|
import { FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
|
||||||
|
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||||
|
|
||||||
|
|
||||||
describe('EPersonFormComponent', () => {
|
describe('EPersonFormComponent', () => {
|
||||||
let component: EPersonFormComponent;
|
let component: EPersonFormComponent;
|
||||||
@@ -99,12 +102,78 @@ describe('EPersonFormComponent', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return createSuccessfulRemoteDataObject$(ePerson);
|
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();
|
authService = new AuthServiceStub();
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||||
isAuthorized: observableOf(true)
|
isAuthorized: observableOf(true),
|
||||||
|
|
||||||
});
|
});
|
||||||
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
||||||
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
||||||
@@ -146,6 +215,131 @@ describe('EPersonFormComponent', () => {
|
|||||||
expect(component).toBeDefined();
|
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', () => {
|
describe('when submitting the form', () => {
|
||||||
let firstName;
|
let firstName;
|
||||||
let lastName;
|
let lastName;
|
||||||
|
@@ -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 { FormGroup } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
DynamicCheckboxModel,
|
DynamicCheckboxModel,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
|
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 { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||||
@@ -32,10 +32,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|||||||
import { RequestService } from '../../../core/data/request.service';
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
import { NoContent } from '../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||||
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-eperson-form',
|
selector: 'ds-eperson-form',
|
||||||
templateUrl: './eperson-form.component.html'
|
templateUrl: './eperson-form.component.html',
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* A form used for creating and editing EPeople
|
* A form used for creating and editing EPeople
|
||||||
@@ -160,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
isImpersonated = false;
|
isImpersonated = false;
|
||||||
|
|
||||||
constructor(public epersonService: EPersonDataService,
|
/**
|
||||||
|
* Subscription to email field value change
|
||||||
|
*/
|
||||||
|
emailValueChangeSubscribe: Subscription;
|
||||||
|
|
||||||
|
constructor(protected changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public epersonService: EPersonDataService,
|
||||||
public groupsDataService: GroupDataService,
|
public groupsDataService: GroupDataService,
|
||||||
private formBuilderService: FormBuilderService,
|
private formBuilderService: FormBuilderService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
@@ -186,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
* This method will initialise the page
|
* This method will initialise the page
|
||||||
*/
|
*/
|
||||||
initialisePage() {
|
initialisePage() {
|
||||||
|
|
||||||
observableCombineLatest(
|
observableCombineLatest(
|
||||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||||
@@ -218,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
name: 'email',
|
name: 'email',
|
||||||
validators: {
|
validators: {
|
||||||
required: null,
|
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,
|
required: true,
|
||||||
|
errorMessages: {
|
||||||
|
emailTaken: 'error.validation.emailTaken',
|
||||||
|
pattern: 'error.validation.NotValidEmail'
|
||||||
|
},
|
||||||
hint: emailHint
|
hint: emailHint
|
||||||
});
|
});
|
||||||
this.canLogIn = new DynamicCheckboxModel(
|
this.canLogIn = new DynamicCheckboxModel(
|
||||||
@@ -259,11 +272,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
canLogIn: eperson != null ? eperson.canLogIn : true,
|
canLogIn: eperson != null ? eperson.canLogIn : true,
|
||||||
requireCertificate: eperson != null ? eperson.requireCertificate : false
|
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();
|
const activeEPerson$ = this.epersonService.getActiveEPerson();
|
||||||
|
|
||||||
this.groups = activeEPerson$.pipe(
|
this.groups = activeEPerson$.pipe(
|
||||||
switchMap((eperson) => {
|
switchMap((eperson) => {
|
||||||
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
|
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
@@ -272,14 +292,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
}),
|
}),
|
||||||
switchMap(([eperson, findListOptions]) => {
|
switchMap(([eperson, findListOptions]) => {
|
||||||
if (eperson != null) {
|
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);
|
return observableOf(undefined);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.canImpersonate$ = activeEPerson$.pipe(
|
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(
|
this.canDelete$ = activeEPerson$.pipe(
|
||||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
||||||
@@ -342,10 +368,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
getFirstCompletedRemoteData()
|
getFirstCompletedRemoteData()
|
||||||
).subscribe((rd: RemoteData<EPerson>) => {
|
).subscribe((rd: RemoteData<EPerson>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', {name: ePersonToCreate.name}));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name }));
|
||||||
this.submitForm.emit(ePersonToCreate);
|
this.submitForm.emit(ePersonToCreate);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', {name: ePersonToCreate.name}));
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name }));
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -381,10 +407,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
const response = this.epersonService.updateEPerson(editedEperson);
|
const response = this.epersonService.updateEPerson(editedEperson);
|
||||||
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
|
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', {name: editedEperson.name}));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name }));
|
||||||
this.submitForm.emit(editedEperson);
|
this.submitForm.emit(editedEperson);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', {name: editedEperson.name}));
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name }));
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -394,6 +420,87 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChange(event) {
|
||||||
|
this.updateGroups({
|
||||||
|
currentPage: event,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start impersonating the EPerson
|
||||||
|
*/
|
||||||
|
impersonate() {
|
||||||
|
this.authService.impersonate(this.epersonInitial.id);
|
||||||
|
this.isImpersonated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
|
||||||
|
* It'll either show a success or error message depending on whether the delete was successful or not.
|
||||||
|
*/
|
||||||
|
delete() {
|
||||||
|
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||||
|
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.brandColor = 'danger';
|
||||||
|
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
|
||||||
|
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
||||||
|
if (confirm) {
|
||||||
|
if (hasValue(eperson.id)) {
|
||||||
|
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||||
|
if (restResponse.hasSucceeded) {
|
||||||
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
||||||
|
this.submitForm.emit();
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
||||||
|
}
|
||||||
|
this.cancelForm.emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop impersonating the EPerson
|
||||||
|
*/
|
||||||
|
stopImpersonating() {
|
||||||
|
this.authService.stopImpersonatingAndRefresh();
|
||||||
|
this.isImpersonated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||||
|
this.requestService.removeByHrefSubstring(eperson.self);
|
||||||
|
});
|
||||||
|
this.initialisePage();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks for the given ePerson if there is already an ePerson in the system with that email
|
* 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
|
* and shows notification if this is the case
|
||||||
@@ -416,17 +523,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Event triggered when the user changes page
|
|
||||||
* @param event
|
|
||||||
*/
|
|
||||||
onPageChange(event) {
|
|
||||||
this.updateGroups({
|
|
||||||
currentPage: event,
|
|
||||||
elementsPerPage: this.config.pageSize
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the list of groups by fetching it from the rest api or cache
|
* Update the list of groups by fetching it from the rest api or cache
|
||||||
*/
|
*/
|
||||||
@@ -435,71 +531,4 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
|
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start impersonating the EPerson
|
|
||||||
*/
|
|
||||||
impersonate() {
|
|
||||||
this.authService.impersonate(this.epersonInitial.id);
|
|
||||||
this.isImpersonated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
|
|
||||||
* It'll either show a success or error message depending on whether the delete was successful or not.
|
|
||||||
*/
|
|
||||||
delete() {
|
|
||||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
|
||||||
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.brandColor = 'danger';
|
|
||||||
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
|
|
||||||
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
|
||||||
if (confirm) {
|
|
||||||
if (hasValue(eperson.id)) {
|
|
||||||
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
|
||||||
if (restResponse.hasSucceeded) {
|
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
|
||||||
this.submitForm.emit();
|
|
||||||
} else {
|
|
||||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
|
||||||
}
|
|
||||||
this.cancelForm.emit();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop impersonating the EPerson
|
|
||||||
*/
|
|
||||||
stopImpersonating() {
|
|
||||||
this.authService.stopImpersonatingAndRefresh();
|
|
||||||
this.isImpersonated = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
|
||||||
*/
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.onCancel();
|
|
||||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
|
||||||
this.paginationService.clearPagination(this.config.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method will ensure that the page gets reset and that the cache is cleared
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
|
||||||
this.requestService.removeByHrefSubstring(eperson.self);
|
|
||||||
});
|
|
||||||
this.initialisePage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
@@ -34,6 +34,7 @@ import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mo
|
|||||||
import { RouterMock } from '../../../shared/mocks/router.mock';
|
import { RouterMock } from '../../../shared/mocks/router.mock';
|
||||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { ValidateGroupExists } from './validators/group-exists.validator';
|
||||||
|
|
||||||
describe('GroupFormComponent', () => {
|
describe('GroupFormComponent', () => {
|
||||||
let component: GroupFormComponent;
|
let component: GroupFormComponent;
|
||||||
@@ -117,7 +118,69 @@ describe('GroupFormComponent', () => {
|
|||||||
return null;
|
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();
|
translateService = getMockTranslateService();
|
||||||
router = new RouterMock();
|
router = new RouterMock();
|
||||||
notificationService = new NotificationsServiceStub();
|
notificationService = new NotificationsServiceStub();
|
||||||
@@ -217,4 +280,72 @@ 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();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -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 { FormGroup } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
combineLatest as observableCombineLatest,
|
combineLatest as observableCombineLatest,
|
||||||
Observable,
|
Observable,
|
||||||
of as observableOf,
|
of as observableOf,
|
||||||
Subscription
|
Subscription,
|
||||||
} from 'rxjs';
|
} 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 { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
|
||||||
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
|
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
|
||||||
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
|
||||||
@@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
|||||||
import {
|
import {
|
||||||
getRemoteDataPayload,
|
getRemoteDataPayload,
|
||||||
getFirstSucceededRemoteData,
|
getFirstSucceededRemoteData,
|
||||||
getFirstCompletedRemoteData
|
getFirstCompletedRemoteData,
|
||||||
|
getFirstSucceededRemoteDataPayload
|
||||||
} from '../../../core/shared/operators';
|
} from '../../../core/shared/operators';
|
||||||
import { AlertType } from '../../../shared/alert/aletr-type';
|
import { AlertType } from '../../../shared/alert/aletr-type';
|
||||||
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
|
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 { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
import { NoContent } from '../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../core/shared/NoContent.model';
|
||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { ValidateGroupExists } from './validators/group-exists.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-group-form',
|
selector: 'ds-group-form',
|
||||||
@@ -65,6 +67,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
* Dynamic models for the inputs of form
|
* Dynamic models for the inputs of form
|
||||||
*/
|
*/
|
||||||
groupName: DynamicInputModel;
|
groupName: DynamicInputModel;
|
||||||
|
groupCommunity: DynamicInputModel;
|
||||||
groupDescription: DynamicTextAreaModel;
|
groupDescription: DynamicTextAreaModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,17 +127,24 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
public AlertTypeEnum = AlertType;
|
public AlertTypeEnum = AlertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription to email field value change
|
||||||
|
*/
|
||||||
|
groupNameValueChangeSubscribe: Subscription;
|
||||||
|
|
||||||
|
|
||||||
constructor(public groupDataService: GroupDataService,
|
constructor(public groupDataService: GroupDataService,
|
||||||
private ePersonDataService: EPersonDataService,
|
private ePersonDataService: EPersonDataService,
|
||||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||||
private formBuilderService: FormBuilderService,
|
private formBuilderService: FormBuilderService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
private authorizationService: AuthorizationDataService,
|
private authorizationService: AuthorizationDataService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
public requestService: RequestService) {
|
public requestService: RequestService,
|
||||||
|
protected changeDetectorRef: ChangeDetectorRef) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -160,8 +170,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
observableCombineLatest(
|
observableCombineLatest(
|
||||||
this.translateService.get(`${this.messagePrefix}.groupName`),
|
this.translateService.get(`${this.messagePrefix}.groupName`),
|
||||||
|
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
|
||||||
this.translateService.get(`${this.messagePrefix}.groupDescription`)
|
this.translateService.get(`${this.messagePrefix}.groupDescription`)
|
||||||
).subscribe(([groupName, groupDescription]) => {
|
).subscribe(([groupName, groupCommunity, groupDescription]) => {
|
||||||
this.groupName = new DynamicInputModel({
|
this.groupName = new DynamicInputModel({
|
||||||
id: 'groupName',
|
id: 'groupName',
|
||||||
label: groupName,
|
label: groupName,
|
||||||
@@ -171,6 +182,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
this.groupCommunity = new DynamicInputModel({
|
||||||
|
id: 'groupCommunity',
|
||||||
|
label: groupCommunity,
|
||||||
|
name: 'groupCommunity',
|
||||||
|
required: false,
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
this.groupDescription = new DynamicTextAreaModel({
|
this.groupDescription = new DynamicTextAreaModel({
|
||||||
id: 'groupDescription',
|
id: 'groupDescription',
|
||||||
label: groupDescription,
|
label: groupDescription,
|
||||||
@@ -182,20 +200,51 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
this.groupDescription,
|
this.groupDescription,
|
||||||
];
|
];
|
||||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
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(
|
this.subs.push(
|
||||||
observableCombineLatest(
|
observableCombineLatest(
|
||||||
this.groupDataService.getActiveGroup(),
|
this.groupDataService.getActiveGroup(),
|
||||||
this.canEdit$
|
this.canEdit$,
|
||||||
).subscribe(([activeGroup, canEdit]) => {
|
this.groupDataService.getActiveGroup()
|
||||||
|
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
|
||||||
|
).subscribe(([activeGroup, canEdit, linkedObject]) => {
|
||||||
|
|
||||||
if (activeGroup != null) {
|
if (activeGroup != null) {
|
||||||
|
|
||||||
|
// Disable group name exists validator
|
||||||
|
this.formGroup.controls.groupName.clearAsyncValidators();
|
||||||
|
|
||||||
this.groupBeingEdited = activeGroup;
|
this.groupBeingEdited = activeGroup;
|
||||||
this.formGroup.patchValue({
|
|
||||||
groupName: activeGroup != null ? activeGroup.name : '',
|
if (linkedObject?.name) {
|
||||||
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
|
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
|
||||||
});
|
this.formGroup.patchValue({
|
||||||
if (!canEdit || activeGroup.permanent) {
|
groupName: activeGroup.name,
|
||||||
this.formGroup.disable();
|
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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -407,6 +456,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.groupDataService.cancelEditGroup();
|
this.groupDataService.cancelEditGroup();
|
||||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
|
|
||||||
|
if ( hasValue(this.groupNameValueChangeSubscribe) ) {
|
||||||
|
this.groupNameValueChangeSubscribe.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,11 +471,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
if (hasValue(group) && hasValue(group._links.object.href)) {
|
if (hasValue(group) && hasValue(group._links.object.href)) {
|
||||||
return this.getLinkedDSO(group).pipe(
|
return this.getLinkedDSO(group).pipe(
|
||||||
map((rd: RemoteData<DSpaceObject>) => {
|
map((rd: RemoteData<DSpaceObject>) => {
|
||||||
if (hasValue(rd) && hasValue(rd.payload)) {
|
return hasValue(rd) && hasValue(rd.payload);
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
catchError(() => observableOf(false)),
|
catchError(() => observableOf(false)),
|
||||||
);
|
);
|
||||||
|
@@ -38,17 +38,22 @@
|
|||||||
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
|
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
|
||||||
|
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
|
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
|
||||||
<td>{{ePerson.eperson.id}}</td>
|
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
[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">
|
<div class="btn-group edit-field">
|
||||||
<button *ngIf="(ePerson.memberOfGroup)"
|
<button *ngIf="(ePerson.memberOfGroup)"
|
||||||
(click)="deleteMemberFromGroup(ePerson)"
|
(click)="deleteMemberFromGroup(ePerson)"
|
||||||
@@ -91,17 +96,22 @@
|
|||||||
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
|
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
|
||||||
|
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
|
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
|
||||||
<td>{{ePerson.eperson.id}}</td>
|
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
[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">
|
<div class="btn-group edit-field">
|
||||||
<button (click)="deleteMemberFromGroup(ePerson)"
|
<button (click)="deleteMemberFromGroup(ePerson)"
|
||||||
class="btn btn-outline-danger btn-sm"
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
@@ -35,17 +35,19 @@
|
|||||||
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
|
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
||||||
|
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
|
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
|
||||||
<td>{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
[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">
|
<div class="btn-group edit-field">
|
||||||
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||||
(click)="deleteSubgroupFromGroup(group)"
|
(click)="deleteSubgroupFromGroup(group)"
|
||||||
@@ -88,17 +90,19 @@
|
|||||||
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
|
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + '.table.name' | 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>
|
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
|
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
|
||||||
<td>{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
[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">
|
<div class="btn-group edit-field">
|
||||||
<button (click)="deleteSubgroupFromGroup(group)"
|
<button (click)="deleteSubgroupFromGroup(group)"
|
||||||
class="btn btn-outline-danger btn-sm deleteButton"
|
class="btn btn-outline-danger btn-sm deleteButton"
|
||||||
|
@@ -17,6 +17,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
|
|||||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||||
import { NoContent } from '../../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||||
|
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys to keep track of specific subscriptions
|
* 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, {
|
switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
|
||||||
currentPage: config.currentPage,
|
currentPage: config.currentPage,
|
||||||
elementsPerPage: config.pageSize
|
elementsPerPage: config.pageSize
|
||||||
}
|
},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
followLink('object')
|
||||||
))
|
))
|
||||||
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
||||||
this.subGroups$.next(rd);
|
this.subGroups$.next(rd);
|
||||||
@@ -217,7 +221,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
|||||||
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
|
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
|
||||||
currentPage: config.currentPage,
|
currentPage: config.currentPage,
|
||||||
elementsPerPage: config.pageSize
|
elementsPerPage: config.pageSize
|
||||||
}))
|
}, true, true, followLink('object')
|
||||||
|
))
|
||||||
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
||||||
this.searchResults$.next(rd);
|
this.searchResults$.next(rd);
|
||||||
}));
|
}));
|
||||||
|
@@ -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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -48,6 +48,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
|
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
|
||||||
<th scope="col">{{messagePrefix + 'table.name' | 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 scope="col">{{messagePrefix + 'table.members' | translate}}</th>
|
||||||
<th>{{messagePrefix + 'table.edit' | translate}}</th>
|
<th>{{messagePrefix + 'table.edit' | translate}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
|
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
|
||||||
<td>{{groupDto.group.id}}</td>
|
<td>{{groupDto.group.id}}</td>
|
||||||
<td>{{groupDto.group.name}}</td>
|
<td>{{groupDto.group.name}}</td>
|
||||||
|
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
|
||||||
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
|
@@ -152,6 +152,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
return createSuccessfulRemoteDataObject$(undefined);
|
return createSuccessfulRemoteDataObject$(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
||||||
setIsAuthorized(true, true);
|
setIsAuthorized(true, true);
|
||||||
paginationService = new PaginationServiceStub();
|
paginationService = new PaginationServiceStub();
|
||||||
@@ -200,6 +201,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('edit buttons', () => {
|
||||||
describe('when the user is a general admin', () => {
|
describe('when the user is a general admin', () => {
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
@@ -213,7 +221,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('should be active', () => {
|
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);
|
expect(editButtonsFound.length).toEqual(2);
|
||||||
editButtonsFound.forEach((editButtonFound) => {
|
editButtonsFound.forEach((editButtonFound) => {
|
||||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||||
@@ -247,7 +255,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('should be active', () => {
|
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);
|
expect(editButtonsFound.length).toEqual(2);
|
||||||
editButtonsFound.forEach((editButtonFound) => {
|
editButtonsFound.forEach((editButtonFound) => {
|
||||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||||
@@ -266,7 +274,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('should not be active', () => {
|
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);
|
expect(editButtonsFound.length).toEqual(2);
|
||||||
editButtonsFound.forEach((editButtonFound) => {
|
editButtonsFound.forEach((editButtonFound) => {
|
||||||
expect(editButtonFound.nativeElement.disabled).toBeTrue();
|
expect(editButtonFound.nativeElement.disabled).toBeTrue();
|
||||||
|
@@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
|||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
import { NoContent } from '../../core/shared/NoContent.model';
|
import { NoContent } from '../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
|
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-groups-registry',
|
selector: 'ds-groups-registry',
|
||||||
@@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
|
return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
|
||||||
currentPage: paginationOptions.currentPage,
|
currentPage: paginationOptions.currentPage,
|
||||||
elementsPerPage: paginationOptions.pageSize
|
elementsPerPage: paginationOptions.pageSize,
|
||||||
});
|
}, true, true, followLink('object'));
|
||||||
}),
|
}),
|
||||||
getAllSucceededRemoteData(),
|
getAllSucceededRemoteData(),
|
||||||
getRemoteDataPayload(),
|
getRemoteDataPayload(),
|
||||||
|
@@ -1,10 +1,23 @@
|
|||||||
<li class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<a href="javascript:void(0);" class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
|
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
|
||||||
|
[ngClass]="{ disabled: !hasLink }"
|
||||||
|
[attr.aria-disabled]="!hasLink"
|
||||||
|
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
||||||
|
[title]="('menu.section.icon.' + section.id) | translate"
|
||||||
|
[routerLink]="itemModel.link"
|
||||||
|
(keyup.space)="navigate($event)"
|
||||||
|
(keyup.enter)="navigate($event)"
|
||||||
|
href="javascript:void(0);"
|
||||||
|
>
|
||||||
|
<div class="shortcut-icon">
|
||||||
<i class="fas fa-{{section.icon}} fa-fw"></i>
|
<i class="fas fa-{{section.icon}} fa-fw"></i>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-collapsible">
|
||||||
|
<div class="toggle">
|
||||||
|
<span id="sidebarName-{{section.id}}" class="section-header-text">
|
||||||
|
{{itemModel.text | translate}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sidebar-collapsible">
|
</div>
|
||||||
<span id="sidebarName-{{section.id}}" class="section-header-text">
|
|
||||||
<a class="nav-item nav-link" tabindex="-1" [routerLink]="itemModel.link">{{itemModel.text | translate}}</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
@@ -5,12 +5,15 @@ import { MenuService } from '../../../shared/menu/menu.service';
|
|||||||
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
||||||
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
||||||
import { MenuSection } from '../../../shared/menu/menu.reducer';
|
import { MenuSection } from '../../../shared/menu/menu.reducer';
|
||||||
|
import { isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a non-expandable section in the admin sidebar
|
* Represents a non-expandable section in the admin sidebar
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-admin-sidebar-section',
|
/* tslint:disable:component-selector */
|
||||||
|
selector: 'li[ds-admin-sidebar-section]',
|
||||||
templateUrl: './admin-sidebar-section.component.html',
|
templateUrl: './admin-sidebar-section.component.html',
|
||||||
styleUrls: ['./admin-sidebar-section.component.scss'],
|
styleUrls: ['./admin-sidebar-section.component.scss'],
|
||||||
|
|
||||||
@@ -23,12 +26,26 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
|
|||||||
*/
|
*/
|
||||||
menuID: MenuID = MenuID.ADMIN;
|
menuID: MenuID = MenuID.ADMIN;
|
||||||
itemModel;
|
itemModel;
|
||||||
constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) {
|
hasLink: boolean;
|
||||||
|
constructor(
|
||||||
|
@Inject('sectionDataProvider') menuSection: MenuSection,
|
||||||
|
protected menuService: MenuService,
|
||||||
|
protected injector: Injector,
|
||||||
|
protected router: Router,
|
||||||
|
) {
|
||||||
super(menuSection, menuService, injector);
|
super(menuSection, menuService, injector);
|
||||||
this.itemModel = menuSection.model as LinkMenuItemModel;
|
this.itemModel = menuSection.model as LinkMenuItemModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.hasLink = isNotEmpty(this.itemModel?.link);
|
||||||
super.ngOnInit();
|
super.ngOnInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigate(event: any): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.hasLink) {
|
||||||
|
this.router.navigate(this.itemModel.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,24 +4,26 @@
|
|||||||
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
|
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
|
||||||
params: {sidebarWidth: (sidebarWidth | async)}
|
params: {sidebarWidth: (sidebarWidth | async)}
|
||||||
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
|
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
|
||||||
*ngIf="menuVisible | async" (mouseenter)="expandPreview($event)"
|
*ngIf="menuVisible | async"
|
||||||
(mouseleave)="collapsePreview($event)"
|
(mouseenter)="handleMouseEnter($event)"
|
||||||
|
(mouseleave)="handleMouseLeave($event)"
|
||||||
role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate">
|
role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate">
|
||||||
<div class="sidebar-top-level-items">
|
<div class="sidebar-top-level-items">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="admin-menu-header sidebar-section">
|
<li class="admin-menu-header">
|
||||||
<a class="shortcut-icon navbar-brand mr-0" href="javascript:void(0);">
|
<div class="sidebar-section">
|
||||||
<span class="logo-wrapper">
|
<div href="javascript:void(0);" class="nav-item d-flex flex-row flex-nowrap py-0">
|
||||||
|
<div class="shortcut-icon navbar-brand logo-wrapper">
|
||||||
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
|
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
|
||||||
[alt]="('menu.header.image.logo') | translate">
|
[alt]="('menu.header.image.logo') | translate">
|
||||||
</span>
|
</div>
|
||||||
</a>
|
<div class="sidebar-collapsible navbar-brand">
|
||||||
<div class="sidebar-collapsible">
|
<div class="mr-0">
|
||||||
<a class="navbar-brand mr-0" href="javascript:void(0);">
|
<h4 class="section-header-text mb-0">{{ 'menu.header.admin' | translate }}</h4>
|
||||||
<h4 class="section-header-text mb-0">{{'menu.header.admin' |
|
</div>
|
||||||
translate}}</h4>
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<ng-container *ngFor="let section of (sections | async)">
|
<ng-container *ngFor="let section of (sections | async)">
|
||||||
@@ -32,22 +34,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<div class="sidebar-section" id="sidebar-collapse-toggle">
|
<div class="sidebar-section" id="sidebar-collapse-toggle">
|
||||||
<a class="nav-item nav-link shortcut-icon"
|
<a class="nav-item nav-link sidebar-section d-flex flex-row flex-nowrap"
|
||||||
href="javascript:void(0);"
|
href="javascript:void(0);"
|
||||||
(click)="toggle($event)">
|
(click)="toggle($event)"
|
||||||
|
(keyup.space)="toggle($event)"
|
||||||
|
>
|
||||||
|
<div class="shortcut-icon">
|
||||||
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
|
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
|
||||||
[title]="'menu.section.icon.pin' | translate"></i>
|
[title]="'menu.section.icon.pin' | translate"></i>
|
||||||
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
|
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
|
||||||
[title]="'menu.section.icon.unpin' | translate"></i>
|
[title]="'menu.section.icon.unpin' | translate"></i>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-collapsible">
|
||||||
|
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
|
||||||
|
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sidebar-collapsible">
|
|
||||||
<a class="nav-item nav-link sidebar-section"
|
|
||||||
href="javascript:void(0);"
|
|
||||||
(click)="toggle($event)">
|
|
||||||
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
|
|
||||||
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
@@ -25,6 +25,11 @@
|
|||||||
.navbar-nav {
|
.navbar-nav {
|
||||||
.admin-menu-header {
|
.admin-menu-header {
|
||||||
background-color: var(--ds-admin-sidebar-header-bg);
|
background-color: var(--ds-admin-sidebar-header-bg);
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
img {
|
img {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -34,6 +39,10 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,26 +53,64 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
background-color: var(--ds-admin-sidebar-bg);
|
background-color: var(--ds-admin-sidebar-bg);
|
||||||
|
overflow-x: visible;
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding-top: var(--bs-spacer);
|
padding-top: var(--bs-spacer);
|
||||||
padding-bottom: var(--bs-spacer);
|
padding-bottom: var(--bs-spacer);
|
||||||
|
background-color: inherit;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
// since links fill the whole sidebar, we _inset_ the outline
|
||||||
|
outline-offset: -4px;
|
||||||
|
|
||||||
|
// replace padding with margins so it doesn't extend over the :focus-visible outline
|
||||||
|
// → can't remove the padding altogether; the icon needs to fill out
|
||||||
|
// the collapsed width of the sidebar for the slide animation to look decent.
|
||||||
|
.shortcut-icon {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-left: var(--ds-icon-padding);
|
||||||
|
margin-right: var(--ds-icon-padding);
|
||||||
|
}
|
||||||
|
.logo-wrapper {
|
||||||
|
margin-right: var(--bs-navbar-padding-x) !important;
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-top: var(--bs-navbar-brand-padding-y);
|
||||||
|
margin-bottom: var(--bs-navbar-brand-padding-y);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-icon {
|
.shortcut-icon {
|
||||||
|
background-color: inherit;
|
||||||
padding-left: var(--ds-icon-padding);
|
padding-left: var(--ds-icon-padding);
|
||||||
padding-right: var(--ds-icon-padding);
|
padding-right: var(--ds-icon-padding);
|
||||||
}
|
|
||||||
.shortcut-icon, .icon-wrapper {
|
|
||||||
background-color: inherit;
|
|
||||||
z-index: var(--ds-icon-z-index);
|
z-index: var(--ds-icon-z-index);
|
||||||
|
align-self: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-collapsible {
|
.sidebar-collapsible {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: var(--bs-spacer);
|
||||||
width: var(--ds-sidebar-items-width);
|
width: var(--ds-sidebar-items-width);
|
||||||
position: relative;
|
position: relative;
|
||||||
a {
|
.toggle {
|
||||||
padding-right: var(--bs-spacer);
|
width: 100%;
|
||||||
width: 100%;
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-top: var(--bs-spacer);
|
||||||
|
|
||||||
|
li a {
|
||||||
|
padding-left: var(--bs-spacer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active > .sidebar-collapsible > .nav-link {
|
&.active > .sidebar-collapsible > .nav-link {
|
||||||
color: var(--bs-navbar-dark-active-color);
|
color: var(--bs-navbar-dark-active-color);
|
||||||
}
|
}
|
||||||
|
@@ -113,25 +113,10 @@ describe('AdminSidebarComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when the collapse icon is clicked', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
spyOn(menuService, 'toggleMenu');
|
|
||||||
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
|
|
||||||
sidebarToggler.triggerEventHandler('click', {
|
|
||||||
preventDefault: () => {/**/
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call toggleMenu on the menuService', () => {
|
|
||||||
expect(menuService.toggleMenu).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the collapse link is clicked', () => {
|
describe('when the collapse link is clicked', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(menuService, 'toggleMenu');
|
spyOn(menuService, 'toggleMenu');
|
||||||
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
|
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > a'));
|
||||||
sidebarToggler.triggerEventHandler('click', {
|
sidebarToggler.triggerEventHandler('click', {
|
||||||
preventDefault: () => {/**/
|
preventDefault: () => {/**/
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Component, Injector, OnInit } from '@angular/core';
|
import { Component, HostListener, Injector, OnInit } from '@angular/core';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { combineLatest, combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
import { combineLatest, combineLatest as observableCombineLatest, Observable, BehaviorSubject } from 'rxjs';
|
||||||
import { first, map, take } from 'rxjs/operators';
|
import { debounceTime, first, map, take, distinctUntilChanged, withLatestFrom } from 'rxjs/operators';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
import { ScriptDataService } from '../../core/data/processes/script-data.service';
|
import { ScriptDataService } from '../../core/data/processes/script-data.service';
|
||||||
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
|
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
|
||||||
@@ -60,6 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
sidebarExpanded: Observable<boolean>;
|
sidebarExpanded: Observable<boolean>;
|
||||||
|
|
||||||
|
inFocus$: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
constructor(protected menuService: MenuService,
|
constructor(protected menuService: MenuService,
|
||||||
protected injector: Injector,
|
protected injector: Injector,
|
||||||
private variableService: CSSVariableService,
|
private variableService: CSSVariableService,
|
||||||
@@ -69,6 +71,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
private scriptDataService: ScriptDataService,
|
private scriptDataService: ScriptDataService,
|
||||||
) {
|
) {
|
||||||
super(menuService, injector);
|
super(menuService, injector);
|
||||||
|
this.inFocus$ = new BehaviorSubject(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,10 +92,25 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
this.sidebarOpen = !collapsed;
|
this.sidebarOpen = !collapsed;
|
||||||
this.sidebarClosed = collapsed;
|
this.sidebarClosed = collapsed;
|
||||||
});
|
});
|
||||||
this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed)
|
this.sidebarExpanded = combineLatest([this.menuCollapsed, this.menuPreviewCollapsed])
|
||||||
.pipe(
|
.pipe(
|
||||||
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
|
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
|
||||||
);
|
);
|
||||||
|
this.inFocus$.pipe(
|
||||||
|
debounceTime(50),
|
||||||
|
distinctUntilChanged(), // disregard focusout in situations like --(focusout)-(focusin)--
|
||||||
|
withLatestFrom(
|
||||||
|
combineLatest([this.menuCollapsed, this.menuPreviewCollapsed])
|
||||||
|
),
|
||||||
|
).subscribe(([inFocus, [collapsed, previewCollapsed]]) => {
|
||||||
|
if (collapsed) {
|
||||||
|
if (inFocus && previewCollapsed) {
|
||||||
|
this.expandPreview(new Event('focusin → expand'));
|
||||||
|
} else if (!inFocus && !previewCollapsed) {
|
||||||
|
this.collapsePreview(new Event('focusout → collapse'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -590,6 +608,32 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('focusin')
|
||||||
|
public handleFocusIn() {
|
||||||
|
this.inFocus$.next(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('focusout')
|
||||||
|
public handleFocusOut() {
|
||||||
|
this.inFocus$.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleMouseEnter(event: any) {
|
||||||
|
if (!this.inFocus$.getValue()) {
|
||||||
|
this.expandPreview(event);
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleMouseLeave(event: any) {
|
||||||
|
if (!this.inFocus$.getValue()) {
|
||||||
|
this.collapsePreview(event);
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
||||||
* @param event The animation event
|
* @param event The animation event
|
||||||
|
@@ -1,27 +1,36 @@
|
|||||||
<li class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
|
<div class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
|
||||||
[@bgColor]="{
|
[@bgColor]="{
|
||||||
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
|
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
|
||||||
params: {endColor: (sidebarActiveBg | async)}}">
|
params: {endColor: (sidebarActiveBg | async)}}">
|
||||||
<div class="icon-wrapper">
|
<div class="nav-item nav-link d-flex flex-row flex-nowrap"
|
||||||
<a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="javascript:void(0);">
|
role="button" tabindex="0"
|
||||||
<i class="fas fa-{{section.icon}} fa-fw"></i>
|
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
||||||
</a>
|
[attr.aria-expanded]="expanded | async"
|
||||||
|
[title]="('menu.section.icon.' + section.id) | translate"
|
||||||
|
(click)="toggleSection($event)"
|
||||||
|
(keyup.space)="toggleSection($event)"
|
||||||
|
(keyup.enter)="toggleSection($event)"
|
||||||
|
>
|
||||||
|
<div class="shortcut-icon h-100">
|
||||||
|
<i class="fas fa-{{section.icon}} fa-fw"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-collapsible">
|
<div class="sidebar-collapsible">
|
||||||
<a class="nav-item nav-link" href="javascript:void(0);" tabindex="-1"
|
<div class="toggle">
|
||||||
(click)="toggleSection($event)">
|
<span id="sidebarName-{{section.id}}" class="section-header-text">
|
||||||
<span id="sidebarName-{{section.id}}" class="section-header-text">
|
<ng-container
|
||||||
<ng-container
|
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
||||||
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
</span>
|
||||||
</span>
|
<i class="fas fa-chevron-right fa-pull-right"
|
||||||
<i class="fas fa-chevron-right fa-pull-right"
|
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'"
|
||||||
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
|
[title]="('menu.section.toggle.' + section.id) | translate"
|
||||||
</a>
|
></i>
|
||||||
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
|
</div>
|
||||||
<li *ngFor="let subSection of (subSections$ | async)">
|
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
|
||||||
<ng-container
|
<li *ngFor="let subSection of (subSections$ | async)">
|
||||||
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
|
<ng-container
|
||||||
</li>
|
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
list-style: disc;
|
list-style: disc;
|
||||||
color: var(--bs-navbar-dark-color);
|
color: var(--bs-navbar-dark-color);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-bottom: calc(-1 * var(--bs-spacer)); // the bottom-most nav-item is padded, no need for double spacing
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-collapsible {
|
.sidebar-collapsible {
|
||||||
|
@@ -10,6 +10,8 @@ import { Component } from '@angular/core';
|
|||||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router.stub';
|
||||||
|
|
||||||
describe('ExpandableAdminSidebarSectionComponent', () => {
|
describe('ExpandableAdminSidebarSectionComponent', () => {
|
||||||
let component: ExpandableAdminSidebarSectionComponent;
|
let component: ExpandableAdminSidebarSectionComponent;
|
||||||
@@ -24,6 +26,7 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
{ provide: 'sectionDataProvider', useValue: { icon: iconString } },
|
{ provide: 'sectionDataProvider', useValue: { icon: iconString } },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||||
|
{ provide: Router, useValue: new RouterStub() },
|
||||||
]
|
]
|
||||||
}).overrideComponent(ExpandableAdminSidebarSectionComponent, {
|
}).overrideComponent(ExpandableAdminSidebarSectionComponent, {
|
||||||
set: {
|
set: {
|
||||||
@@ -46,29 +49,14 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set the right icon', () => {
|
it('should set the right icon', () => {
|
||||||
const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas'));
|
const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas'));
|
||||||
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when the icon is clicked', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
spyOn(menuService, 'toggleActiveSection');
|
|
||||||
const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon'));
|
|
||||||
sidebarToggler.triggerEventHandler('click', {
|
|
||||||
preventDefault: () => {/**/
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call toggleActiveSection on the menuService', () => {
|
|
||||||
expect(menuService.toggleActiveSection).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the header text is clicked', () => {
|
describe('when the header text is clicked', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(menuService, 'toggleActiveSection');
|
spyOn(menuService, 'toggleActiveSection');
|
||||||
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a'));
|
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item'));
|
||||||
sidebarToggler.triggerEventHandler('click', {
|
sidebarToggler.triggerEventHandler('click', {
|
||||||
preventDefault: () => {/**/
|
preventDefault: () => {/**/
|
||||||
}
|
}
|
||||||
|
@@ -9,12 +9,14 @@ import { MenuService } from '../../../shared/menu/menu.service';
|
|||||||
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
|
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a expandable section in the sidebar
|
* Represents a expandable section in the sidebar
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-expandable-admin-sidebar-section',
|
/* tslint:disable:component-selector */
|
||||||
|
selector: 'li[ds-expandable-admin-sidebar-section]',
|
||||||
templateUrl: './expandable-admin-sidebar-section.component.html',
|
templateUrl: './expandable-admin-sidebar-section.component.html',
|
||||||
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
|
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
|
||||||
animations: [rotate, slide, bgColor]
|
animations: [rotate, slide, bgColor]
|
||||||
@@ -48,9 +50,14 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
|
|||||||
*/
|
*/
|
||||||
expanded: Observable<boolean>;
|
expanded: Observable<boolean>;
|
||||||
|
|
||||||
constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService,
|
constructor(
|
||||||
private variableService: CSSVariableService, protected injector: Injector) {
|
@Inject('sectionDataProvider') menuSection,
|
||||||
super(menuSection, menuService, injector);
|
protected menuService: MenuService,
|
||||||
|
private variableService: CSSVariableService,
|
||||||
|
protected injector: Injector,
|
||||||
|
protected router: Router,
|
||||||
|
) {
|
||||||
|
super(menuSection, menuService, injector, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -4,7 +4,7 @@ import { Collection } from './core/shared/collection.model';
|
|||||||
import { Item } from './core/shared/item.model';
|
import { Item } from './core/shared/item.model';
|
||||||
import { getCommunityPageRoute } from './community-page/community-page-routing-paths';
|
import { getCommunityPageRoute } from './community-page/community-page-routing-paths';
|
||||||
import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths';
|
import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths';
|
||||||
import { getItemPageRoute } from './item-page/item-page-routing-paths';
|
import { getItemModuleRoute, getItemPageRoute } from './item-page/item-page-routing-paths';
|
||||||
import { hasValue } from './shared/empty.util';
|
import { hasValue } from './shared/empty.util';
|
||||||
import { URLCombiner } from './core/url-combiner/url-combiner';
|
import { URLCombiner } from './core/url-combiner/url-combiner';
|
||||||
|
|
||||||
@@ -22,6 +22,15 @@ export function getBitstreamModuleRoute() {
|
|||||||
export function getBitstreamDownloadRoute(bitstream): string {
|
export function getBitstreamDownloadRoute(bitstream): string {
|
||||||
return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
|
return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
|
||||||
}
|
}
|
||||||
|
export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: string, queryParams: any } {
|
||||||
|
const url = new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString();
|
||||||
|
return {
|
||||||
|
routerLink: url,
|
||||||
|
queryParams: {
|
||||||
|
bitstream: bitstream.uuid
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const ADMIN_MODULE_PATH = 'admin';
|
export const ADMIN_MODULE_PATH = 'admin';
|
||||||
|
|
||||||
@@ -90,3 +99,8 @@ export const ACCESS_CONTROL_MODULE_PATH = 'access-control';
|
|||||||
export function getAccessControlModuleRoute() {
|
export function getAccessControlModuleRoute() {
|
||||||
return `/${ACCESS_CONTROL_MODULE_PATH}`;
|
return `/${ACCESS_CONTROL_MODULE_PATH}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const REQUEST_COPY_MODULE_PATH = 'request-a-copy';
|
||||||
|
export function getRequestCopyModulePath() {
|
||||||
|
return `/${REQUEST_COPY_MODULE_PATH}`;
|
||||||
|
}
|
||||||
|
@@ -14,7 +14,7 @@ import {
|
|||||||
PROFILE_MODULE_PATH,
|
PROFILE_MODULE_PATH,
|
||||||
REGISTER_PATH,
|
REGISTER_PATH,
|
||||||
WORKFLOW_ITEM_MODULE_PATH,
|
WORKFLOW_ITEM_MODULE_PATH,
|
||||||
LEGACY_BITSTREAM_MODULE_PATH,
|
LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH,
|
||||||
} from './app-routing-paths';
|
} from './app-routing-paths';
|
||||||
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
|
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
|
||||||
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
|
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
|
||||||
@@ -180,6 +180,11 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
|
|||||||
path: INFO_MODULE_PATH,
|
path: INFO_MODULE_PATH,
|
||||||
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule),
|
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: REQUEST_COPY_MODULE_PATH,
|
||||||
|
loadChildren: () => import('./request-copy/request-copy.module').then((m) => m.RequestCopyModule),
|
||||||
|
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: FORBIDDEN_PATH,
|
path: FORBIDDEN_PATH,
|
||||||
component: ThemedForbiddenComponent
|
component: ThemedForbiddenComponent
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
Optional,
|
Optional,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
|
import {
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
NavigationCancel,
|
||||||
|
NavigationEnd,
|
||||||
|
NavigationStart, ResolveEnd,
|
||||||
|
Router,
|
||||||
|
} from '@angular/router';
|
||||||
|
|
||||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
import { select, Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
*/
|
*/
|
||||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the idle modal is is currently open
|
* Whether or not the idle modal is is currently open
|
||||||
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
// the theme css will never download server side, so this should only happen on the browser
|
// the theme css will never download server side, so this should only happen on the browser
|
||||||
this.isThemeLoading$.next(true);
|
this.isThemeCSSLoading$.next(true);
|
||||||
}
|
}
|
||||||
if (hasValue(themeName)) {
|
if (hasValue(themeName)) {
|
||||||
this.setThemeCss(themeName);
|
this.setThemeCss(themeName);
|
||||||
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.router.events.pipe(
|
let resolveEndFound = false;
|
||||||
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
|
this.router.events.subscribe((event) => {
|
||||||
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
|
|
||||||
delay(0)
|
|
||||||
).subscribe((event) => {
|
|
||||||
if (event instanceof NavigationStart) {
|
if (event instanceof NavigationStart) {
|
||||||
|
resolveEndFound = false;
|
||||||
this.isRouteLoading$.next(true);
|
this.isRouteLoading$.next(true);
|
||||||
|
this.isThemeLoading$.next(true);
|
||||||
|
} else if (event instanceof ResolveEnd) {
|
||||||
|
resolveEndFound = true;
|
||||||
|
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
|
||||||
|
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
|
||||||
|
switchMap((changed) => {
|
||||||
|
if (changed) {
|
||||||
|
return this.isThemeCSSLoading$;
|
||||||
|
} else {
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).subscribe((changed) => {
|
||||||
|
this.isThemeLoading$.next(changed);
|
||||||
|
});
|
||||||
} else if (
|
} else if (
|
||||||
event instanceof NavigationEnd ||
|
event instanceof NavigationEnd ||
|
||||||
event instanceof NavigationCancel
|
event instanceof NavigationCancel
|
||||||
) {
|
) {
|
||||||
|
if (!resolveEndFound) {
|
||||||
|
this.isThemeLoading$.next(false);
|
||||||
|
}
|
||||||
this.isRouteLoading$.next(false);
|
this.isRouteLoading$.next(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// the fact that this callback is used, proves we're on the browser.
|
// the fact that this callback is used, proves we're on the browser.
|
||||||
this.isThemeLoading$.next(false);
|
this.isThemeCSSLoading$.next(false);
|
||||||
};
|
};
|
||||||
head.appendChild(link);
|
head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects';
|
|||||||
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
||||||
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
||||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||||
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
|
import {
|
||||||
|
DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||||
|
DYNAMIC_MATCHER_PROVIDERS,
|
||||||
|
DynamicErrorMessagesMatcher
|
||||||
|
} from '@ng-dynamic-forms/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
|
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
|
|||||||
|
|
||||||
import { UUIDService } from './core/shared/uuid.service';
|
import { UUIDService } from './core/shared/uuid.service';
|
||||||
import { CookieService } from './core/services/cookie.service';
|
import { CookieService } from './core/services/cookie.service';
|
||||||
|
import { AbstractControl } from '@angular/forms';
|
||||||
|
|
||||||
export function getBase() {
|
export function getBase() {
|
||||||
return environment.ui.nameSpace;
|
return environment.ui.nameSpace;
|
||||||
@@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer<AppState>[] {
|
|||||||
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
|
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition for displaying error messages on email form field
|
||||||
|
*/
|
||||||
|
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
|
||||||
|
(control: AbstractControl, model: any, hasFocus: boolean) => {
|
||||||
|
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
|
||||||
|
};
|
||||||
|
|
||||||
const IMPORTS = [
|
const IMPORTS = [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
@@ -146,6 +159,10 @@ const PROVIDERS = [
|
|||||||
multi: true,
|
multi: true,
|
||||||
deps: [ CookieService, UUIDService ]
|
deps: [ CookieService, UUIDService ]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||||
|
useValue: ValidateEmailErrorStateMatcher
|
||||||
|
},
|
||||||
...DYNAMIC_MATCHER_PROVIDERS,
|
...DYNAMIC_MATCHER_PROVIDERS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@@ -8,7 +8,7 @@ import { By } from '@angular/platform-browser';
|
|||||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock';
|
import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { DebugElement } from '@angular/core';
|
import { DebugElement } from '@angular/core';
|
||||||
|
|
||||||
describe('BreadcrumbsComponent', () => {
|
describe('BreadcrumbsComponent', () => {
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
|
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
|
||||||
import { BreadcrumbsService } from './breadcrumbs.service';
|
import { BreadcrumbsService } from './breadcrumbs.service';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component representing the breadcrumbs of a page
|
* Component representing the breadcrumbs of a page
|
||||||
|
@@ -1,18 +1,27 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
DynamicFormControlModel,
|
DynamicFormControlModel,
|
||||||
|
DynamicFormOptionConfig,
|
||||||
DynamicFormService,
|
DynamicFormService,
|
||||||
DynamicInputModel,
|
DynamicSelectModel
|
||||||
DynamicTextAreaModel
|
|
||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
|
|
||||||
import { Collection } from '../../core/shared/collection.model';
|
import { Collection } from '../../core/shared/collection.model';
|
||||||
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
|
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
import { RequestService } from '../../core/data/request.service';
|
import { RequestService } from '../../core/data/request.service';
|
||||||
import { ObjectCacheService } from '../../core/cache/object-cache.service';
|
import { ObjectCacheService } from '../../core/cache/object-cache.service';
|
||||||
|
import { EntityTypeService } from '../../core/data/entity-type.service';
|
||||||
|
import { ItemType } from '../../core/shared/item-relationships/item-type.model';
|
||||||
|
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||||
|
import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators';
|
||||||
|
import { collectionFormEntityTypeSelectionConfig, collectionFormModels, } from './collection-form.models';
|
||||||
|
import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form used for creating and editing collections
|
* Form used for creating and editing collections
|
||||||
@@ -22,7 +31,7 @@ import { ObjectCacheService } from '../../core/cache/object-cache.service';
|
|||||||
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
|
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
|
||||||
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
|
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
|
||||||
})
|
})
|
||||||
export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
export class CollectionFormComponent extends ComColFormComponent<Collection> implements OnInit {
|
||||||
/**
|
/**
|
||||||
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
|
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
|
||||||
*/
|
*/
|
||||||
@@ -34,46 +43,16 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
|||||||
type = Collection.type;
|
type = Collection.type;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dynamic form fields used for creating/editing a collection
|
* The dynamic form field used for entity type selection
|
||||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
* @type {DynamicSelectModel<string>}
|
||||||
*/
|
*/
|
||||||
formModel: DynamicFormControlModel[] = [
|
entityTypeSelection: DynamicSelectModel<string> = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig);
|
||||||
new DynamicInputModel({
|
|
||||||
id: 'title',
|
/**
|
||||||
name: 'dc.title',
|
* The dynamic form fields used for creating/editing a collection
|
||||||
required: true,
|
* @type {DynamicFormControlModel[]}
|
||||||
validators: {
|
*/
|
||||||
required: null
|
formModel: DynamicFormControlModel[];
|
||||||
},
|
|
||||||
errorMessages: {
|
|
||||||
required: 'Please enter a name for this title'
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'description',
|
|
||||||
name: 'dc.description',
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'abstract',
|
|
||||||
name: 'dc.description.abstract',
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'rights',
|
|
||||||
name: 'dc.rights',
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'tableofcontents',
|
|
||||||
name: 'dc.description.tableofcontents',
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'license',
|
|
||||||
name: 'dc.rights.license',
|
|
||||||
}),
|
|
||||||
new DynamicTextAreaModel({
|
|
||||||
id: 'provenance',
|
|
||||||
name: 'dc.description.provenance',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
public constructor(protected formService: DynamicFormService,
|
public constructor(protected formService: DynamicFormService,
|
||||||
protected translate: TranslateService,
|
protected translate: TranslateService,
|
||||||
@@ -81,7 +60,43 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
|||||||
protected authService: AuthService,
|
protected authService: AuthService,
|
||||||
protected dsoService: CommunityDataService,
|
protected dsoService: CommunityDataService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected objectCache: ObjectCacheService) {
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected entityTypeService: EntityTypeService) {
|
||||||
super(formService, translate, notificationsService, authService, requestService, objectCache);
|
super(formService, translate, notificationsService, authService, requestService, objectCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
|
||||||
|
let currentRelationshipValue: MetadataValue[];
|
||||||
|
if (this.dso && this.dso.metadata) {
|
||||||
|
currentRelationshipValue = this.dso.metadata['dspace.entity.type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities$: Observable<ItemType[]> = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe(
|
||||||
|
getFirstSucceededRemoteListPayload()
|
||||||
|
);
|
||||||
|
|
||||||
|
// retrieve all entity types to populate the dropdowns selection
|
||||||
|
entities$.subscribe((entityTypes: ItemType[]) => {
|
||||||
|
|
||||||
|
entityTypes
|
||||||
|
.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE)
|
||||||
|
.forEach((type: ItemType, index: number) => {
|
||||||
|
this.entityTypeSelection.add({
|
||||||
|
disabled: false,
|
||||||
|
label: type.label,
|
||||||
|
value: type.label
|
||||||
|
} as DynamicFormOptionConfig<string>);
|
||||||
|
if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) {
|
||||||
|
this.entityTypeSelection.select(index);
|
||||||
|
this.entityTypeSelection.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.formModel = [...collectionFormModels, this.entityTypeSelection];
|
||||||
|
|
||||||
|
super.ngOnInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,46 @@
|
|||||||
|
import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
|
||||||
|
import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model';
|
||||||
|
|
||||||
|
export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig<string> = {
|
||||||
|
id: 'entityType',
|
||||||
|
name: 'dspace.entity.type',
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dynamic form fields used for creating/editing a collection
|
||||||
|
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||||
|
*/
|
||||||
|
export const collectionFormModels: DynamicFormControlModel[] = [
|
||||||
|
new DynamicInputModel({
|
||||||
|
id: 'title',
|
||||||
|
name: 'dc.title',
|
||||||
|
required: true,
|
||||||
|
validators: {
|
||||||
|
required: null
|
||||||
|
},
|
||||||
|
errorMessages: {
|
||||||
|
required: 'Please enter a name for this title'
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new DynamicTextAreaModel({
|
||||||
|
id: 'description',
|
||||||
|
name: 'dc.description',
|
||||||
|
}),
|
||||||
|
new DynamicTextAreaModel({
|
||||||
|
id: 'abstract',
|
||||||
|
name: 'dc.description.abstract',
|
||||||
|
}),
|
||||||
|
new DynamicTextAreaModel({
|
||||||
|
id: 'rights',
|
||||||
|
name: 'dc.rights',
|
||||||
|
}),
|
||||||
|
new DynamicTextAreaModel({
|
||||||
|
id: 'tableofcontents',
|
||||||
|
name: 'dc.description.tableofcontents',
|
||||||
|
}),
|
||||||
|
new DynamicTextAreaModel({
|
||||||
|
id: 'license',
|
||||||
|
name: 'dc.rights.license',
|
||||||
|
})
|
||||||
|
];
|
@@ -27,7 +27,7 @@ import { ItemSelectComponent } from '../../shared/object-select/item-select/item
|
|||||||
import { ObjectSelectService } from '../../shared/object-select/object-select.service';
|
import { ObjectSelectService } from '../../shared/object-select/object-select.service';
|
||||||
import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service.stub';
|
import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service.stub';
|
||||||
import { VarDirective } from '../../shared/utils/var.directive';
|
import { VarDirective } from '../../shared/utils/var.directive';
|
||||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { RouteService } from '../../core/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { ErrorComponent } from '../../shared/error/error.component';
|
import { ErrorComponent } from '../../shared/error/error.component';
|
||||||
import { LoadingComponent } from '../../shared/loading/loading.component';
|
import { LoadingComponent } from '../../shared/loading/loading.component';
|
||||||
|
@@ -0,0 +1,54 @@
|
|||||||
|
<div *ngVar="(contentSource$ |async) as contentSource">
|
||||||
|
<div class="container-fluid" *ngIf="shouldShow">
|
||||||
|
<h4>{{ 'collection.source.controls.head' | translate }}</h4>
|
||||||
|
<div>
|
||||||
|
<span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span>
|
||||||
|
<span>{{contentSource?.harvestStatus}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-weight-bold">{{'collection.source.controls.harvest.start' | translate}}</span>
|
||||||
|
<span>{{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-weight-bold">{{'collection.source.controls.harvest.last' | translate}}</span>
|
||||||
|
<span>{{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-weight-bold">{{'collection.source.controls.harvest.message' | translate}}</span>
|
||||||
|
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button *ngIf="!(testConfigRunning$ |async)" class="btn btn-secondary"
|
||||||
|
[disabled]="!(isEnabled)"
|
||||||
|
(click)="testConfiguration(contentSource)">
|
||||||
|
<span>{{'collection.source.controls.test.submit' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="(testConfigRunning$ |async)" class="btn btn-secondary"
|
||||||
|
[disabled]="true">
|
||||||
|
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||||
|
<span>{{'collection.source.controls.test.running' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="!(importRunning$ |async)" class="btn btn-primary"
|
||||||
|
[disabled]="!(isEnabled)"
|
||||||
|
(click)="importNow()">
|
||||||
|
<span class="d-none d-sm-inline">{{'collection.source.controls.import.submit' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="(importRunning$ |async)" class="btn btn-primary"
|
||||||
|
[disabled]="true">
|
||||||
|
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||||
|
<span class="d-none d-sm-inline">{{'collection.source.controls.import.running' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="!(reImportRunning$ |async)" class="btn btn-primary"
|
||||||
|
[disabled]="!(isEnabled)"
|
||||||
|
(click)="resetAndReimport()">
|
||||||
|
<span class="d-none d-sm-inline"> {{'collection.source.controls.reset.submit' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="(reImportRunning$ |async)" class="btn btn-primary"
|
||||||
|
[disabled]="true">
|
||||||
|
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
|
||||||
|
<span class="d-none d-sm-inline"> {{'collection.source.controls.reset.running' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,3 @@
|
|||||||
|
.spinner-button {
|
||||||
|
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
|
||||||
|
}
|
@@ -0,0 +1,232 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { ContentSource } from '../../../../core/shared/content-source.model';
|
||||||
|
import { Collection } from '../../../../core/shared/collection.model';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { CollectionDataService } from '../../../../core/data/collection-data.service';
|
||||||
|
import { RequestService } from '../../../../core/data/request.service';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
|
||||||
|
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
|
||||||
|
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||||
|
import { Process } from '../../../../process-page/processes/process.model';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { CollectionSourceControlsComponent } from './collection-source-controls.component';
|
||||||
|
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||||
|
import { getTestScheduler } from 'jasmine-marbles';
|
||||||
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { VarDirective } from '../../../../shared/utils/var.directive';
|
||||||
|
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
|
||||||
|
|
||||||
|
describe('CollectionSourceControlsComponent', () => {
|
||||||
|
let comp: CollectionSourceControlsComponent;
|
||||||
|
let fixture: ComponentFixture<CollectionSourceControlsComponent>;
|
||||||
|
|
||||||
|
const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4';
|
||||||
|
let contentSource: ContentSource;
|
||||||
|
let collection: Collection;
|
||||||
|
let process: Process;
|
||||||
|
let bitstream: Bitstream;
|
||||||
|
|
||||||
|
let scriptDataService: ScriptDataService;
|
||||||
|
let processDataService: ProcessDataService;
|
||||||
|
let requestService: RequestService;
|
||||||
|
let notificationsService;
|
||||||
|
let collectionService: CollectionDataService;
|
||||||
|
let httpClient: HttpClient;
|
||||||
|
let bitstreamService: BitstreamDataService;
|
||||||
|
let scheduler: TestScheduler;
|
||||||
|
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
scheduler = getTestScheduler();
|
||||||
|
contentSource = Object.assign(new ContentSource(), {
|
||||||
|
uuid: uuid,
|
||||||
|
metadataConfigs: [
|
||||||
|
{
|
||||||
|
id: 'dc',
|
||||||
|
label: 'Simple Dublin Core',
|
||||||
|
nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qdc',
|
||||||
|
label: 'Qualified Dublin Core',
|
||||||
|
nameSpace: 'http://purl.org/dc/terms/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dim',
|
||||||
|
label: 'DSpace Intermediate Metadata',
|
||||||
|
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
oaiSource: 'oai-harvest-source',
|
||||||
|
oaiSetId: 'oai-set-id',
|
||||||
|
_links: {self: {href: 'contentsource-selflink'}}
|
||||||
|
});
|
||||||
|
process = Object.assign(new Process(), {
|
||||||
|
processId: 'process-id', processStatus: 'COMPLETED',
|
||||||
|
_links: {output: {href: 'output-href'}}
|
||||||
|
});
|
||||||
|
|
||||||
|
bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}});
|
||||||
|
|
||||||
|
collection = Object.assign(new Collection(), {
|
||||||
|
uuid: 'fake-collection-id',
|
||||||
|
_links: {self: {href: 'collection-selflink'}}
|
||||||
|
});
|
||||||
|
notificationsService = new NotificationsServiceStub();
|
||||||
|
collectionService = jasmine.createSpyObj('collectionService', {
|
||||||
|
getContentSource: createSuccessfulRemoteDataObject$(contentSource),
|
||||||
|
findByHref: createSuccessfulRemoteDataObject$(collection)
|
||||||
|
});
|
||||||
|
scriptDataService = jasmine.createSpyObj('scriptDataService', {
|
||||||
|
invoke: createSuccessfulRemoteDataObject$(process),
|
||||||
|
});
|
||||||
|
processDataService = jasmine.createSpyObj('processDataService', {
|
||||||
|
findById: createSuccessfulRemoteDataObject$(process),
|
||||||
|
});
|
||||||
|
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||||
|
findByHref: createSuccessfulRemoteDataObject$(bitstream),
|
||||||
|
});
|
||||||
|
httpClient = jasmine.createSpyObj('httpClient', {
|
||||||
|
get: observableOf('Script text'),
|
||||||
|
});
|
||||||
|
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||||
|
declarations: [CollectionSourceControlsComponent, VarDirective],
|
||||||
|
providers: [
|
||||||
|
{provide: ScriptDataService, useValue: scriptDataService},
|
||||||
|
{provide: ProcessDataService, useValue: processDataService},
|
||||||
|
{provide: RequestService, useValue: requestService},
|
||||||
|
{provide: NotificationsService, useValue: notificationsService},
|
||||||
|
{provide: CollectionDataService, useValue: collectionService},
|
||||||
|
{provide: HttpClient, useValue: httpClient},
|
||||||
|
{provide: BitstreamDataService, useValue: bitstreamService}
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(CollectionSourceControlsComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.isEnabled = true;
|
||||||
|
comp.collection = collection;
|
||||||
|
comp.shouldShow = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
describe('init', () => {
|
||||||
|
it('should', () => {
|
||||||
|
expect(comp).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('testConfiguration', () => {
|
||||||
|
it('should invoke a script and ping the resulting process until completed and show the resulting info', () => {
|
||||||
|
comp.testConfiguration(contentSource);
|
||||||
|
scheduler.flush();
|
||||||
|
|
||||||
|
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||||
|
{name: '-g', value: null},
|
||||||
|
{name: '-a', value: contentSource.oaiSource},
|
||||||
|
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||||
|
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
|
||||||
|
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('importNow', () => {
|
||||||
|
it('should invoke a script that will start the harvest', () => {
|
||||||
|
comp.importNow();
|
||||||
|
scheduler.flush();
|
||||||
|
|
||||||
|
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||||
|
{name: '-r', value: null},
|
||||||
|
{name: '-c', value: collection.uuid},
|
||||||
|
], []);
|
||||||
|
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||||
|
expect(notificationsService.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('resetAndReimport', () => {
|
||||||
|
it('should invoke a script that will start the harvest', () => {
|
||||||
|
comp.resetAndReimport();
|
||||||
|
scheduler.flush();
|
||||||
|
|
||||||
|
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
|
||||||
|
{name: '-o', value: null},
|
||||||
|
{name: '-c', value: collection.uuid},
|
||||||
|
], []);
|
||||||
|
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||||
|
expect(notificationsService.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('the controls', () => {
|
||||||
|
it('should be shown when shouldShow is true', () => {
|
||||||
|
comp.shouldShow = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||||
|
expect(buttons.length).toEqual(3);
|
||||||
|
});
|
||||||
|
it('should be shown when shouldShow is false', () => {
|
||||||
|
comp.shouldShow = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||||
|
expect(buttons.length).toEqual(0);
|
||||||
|
});
|
||||||
|
it('should be disabled when isEnabled is false', () => {
|
||||||
|
comp.shouldShow = true;
|
||||||
|
comp.isEnabled = false;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||||
|
|
||||||
|
expect(buttons[0].nativeElement.disabled).toBeTrue();
|
||||||
|
expect(buttons[1].nativeElement.disabled).toBeTrue();
|
||||||
|
expect(buttons[2].nativeElement.disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
it('should be enabled when isEnabled is true', () => {
|
||||||
|
comp.shouldShow = true;
|
||||||
|
comp.isEnabled = true;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||||
|
|
||||||
|
expect(buttons[0].nativeElement.disabled).toBeFalse();
|
||||||
|
expect(buttons[1].nativeElement.disabled).toBeFalse();
|
||||||
|
expect(buttons[2].nativeElement.disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
it('should call the corresponding button when clicked', () => {
|
||||||
|
spyOn(comp, 'testConfiguration');
|
||||||
|
spyOn(comp, 'importNow');
|
||||||
|
spyOn(comp, 'resetAndReimport');
|
||||||
|
|
||||||
|
comp.shouldShow = true;
|
||||||
|
comp.isEnabled = true;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||||
|
|
||||||
|
buttons[0].triggerEventHandler('click', null);
|
||||||
|
expect(comp.testConfiguration).toHaveBeenCalled();
|
||||||
|
|
||||||
|
buttons[1].triggerEventHandler('click', null);
|
||||||
|
expect(comp.importNow).toHaveBeenCalled();
|
||||||
|
|
||||||
|
buttons[2].triggerEventHandler('click', null);
|
||||||
|
expect(comp.resetAndReimport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,231 @@
|
|||||||
|
import { Component, Input, OnDestroy } from '@angular/core';
|
||||||
|
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||||
|
import { ContentSource } from '../../../../core/shared/content-source.model';
|
||||||
|
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
|
||||||
|
import {
|
||||||
|
getAllCompletedRemoteData,
|
||||||
|
getAllSucceededRemoteDataPayload,
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
getFirstSucceededRemoteDataPayload
|
||||||
|
} from '../../../../core/shared/operators';
|
||||||
|
import { filter, map, switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
|
||||||
|
import { ProcessStatus } from '../../../../process-page/processes/process-status.model';
|
||||||
|
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||||
|
import { RequestService } from '../../../../core/data/request.service';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { Collection } from '../../../../core/shared/collection.model';
|
||||||
|
import { CollectionDataService } from '../../../../core/data/collection-data.service';
|
||||||
|
import { Process } from '../../../../process-page/processes/process.model';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
|
||||||
|
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that contains the controls to run, reset and test the harvest
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-collection-source-controls',
|
||||||
|
styleUrls: ['./collection-source-controls.component.scss'],
|
||||||
|
templateUrl: './collection-source-controls.component.html',
|
||||||
|
})
|
||||||
|
export class CollectionSourceControlsComponent implements OnDestroy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the controls be enabled.
|
||||||
|
*/
|
||||||
|
@Input() isEnabled: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current collection
|
||||||
|
*/
|
||||||
|
@Input() collection: Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the control section be shown
|
||||||
|
*/
|
||||||
|
@Input() shouldShow: boolean;
|
||||||
|
|
||||||
|
contentSource$: Observable<ContentSource>;
|
||||||
|
private subs: Subscription[] = [];
|
||||||
|
|
||||||
|
testConfigRunning$ = new BehaviorSubject(false);
|
||||||
|
importRunning$ = new BehaviorSubject(false);
|
||||||
|
reImportRunning$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
constructor(private scriptDataService: ScriptDataService,
|
||||||
|
private processDataService: ProcessDataService,
|
||||||
|
private requestService: RequestService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private collectionService: CollectionDataService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private httpClient: HttpClient,
|
||||||
|
private bitstreamService: BitstreamDataService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// ensure the contentSource gets updated after being set to stale
|
||||||
|
this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe(
|
||||||
|
getAllSucceededRemoteDataPayload(),
|
||||||
|
switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)),
|
||||||
|
getAllSucceededRemoteDataPayload()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the provided content source's configuration.
|
||||||
|
* @param contentSource - The content source to be tested
|
||||||
|
*/
|
||||||
|
testConfiguration(contentSource) {
|
||||||
|
this.testConfigRunning$.next(true);
|
||||||
|
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||||
|
{name: '-g', value: null},
|
||||||
|
{name: '-a', value: contentSource.oaiSource},
|
||||||
|
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
|
||||||
|
], []).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
tap((rd) => {
|
||||||
|
if (rd.hasFailed) {
|
||||||
|
// show a notification when the script invocation fails
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.test.submit.error'));
|
||||||
|
this.testConfigRunning$.next(false);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
|
||||||
|
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||||
|
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||||
|
getAllCompletedRemoteData(),
|
||||||
|
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||||
|
map((rd) => rd.payload),
|
||||||
|
hasValueOperator(),
|
||||||
|
).subscribe((process: Process) => {
|
||||||
|
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||||
|
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
// Ping the current process state every 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
|
||||||
|
this.testConfigRunning$.next(false);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||||
|
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
|
||||||
|
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
|
||||||
|
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
|
||||||
|
.replaceAll('The script has started', '')
|
||||||
|
.replaceAll('The script has completed', '');
|
||||||
|
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.testConfigRunning$.next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the harvest for the current collection
|
||||||
|
*/
|
||||||
|
importNow() {
|
||||||
|
this.importRunning$.next(true);
|
||||||
|
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||||
|
{name: '-r', value: null},
|
||||||
|
{name: '-c', value: this.collection.uuid},
|
||||||
|
], [])
|
||||||
|
.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
tap((rd) => {
|
||||||
|
if (rd.hasFailed) {
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.import.submit.error'));
|
||||||
|
this.importRunning$.next(false);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.success(this.translateService.get('collection.source.controls.import.submit.success'));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||||
|
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||||
|
getAllCompletedRemoteData(),
|
||||||
|
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||||
|
map((rd) => rd.payload),
|
||||||
|
hasValueOperator(),
|
||||||
|
).subscribe((process) => {
|
||||||
|
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||||
|
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
// Ping the current process state every 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
|
||||||
|
this.importRunning$.next(false);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||||
|
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||||
|
this.importRunning$.next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset and reimport the current collection
|
||||||
|
*/
|
||||||
|
resetAndReimport() {
|
||||||
|
this.reImportRunning$.next(true);
|
||||||
|
this.subs.push(this.scriptDataService.invoke('harvest', [
|
||||||
|
{name: '-o', value: null},
|
||||||
|
{name: '-c', value: this.collection.uuid},
|
||||||
|
], [])
|
||||||
|
.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
tap((rd) => {
|
||||||
|
if (rd.hasFailed) {
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.submit.error'));
|
||||||
|
this.reImportRunning$.next(false);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.submit.success'));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||||
|
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||||
|
getAllCompletedRemoteData(),
|
||||||
|
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||||
|
map((rd) => rd.payload),
|
||||||
|
hasValueOperator(),
|
||||||
|
).subscribe((process) => {
|
||||||
|
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||||
|
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
// Ping the current process state every 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||||
|
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
|
||||||
|
this.reImportRunning$.next(false);
|
||||||
|
}
|
||||||
|
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||||
|
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||||
|
this.reImportRunning$.next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs.forEach((sub) => {
|
||||||
|
if (hasValue(sub)) {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,57 +1,74 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="d-inline-block float-right">
|
<div class="d-inline-block float-right">
|
||||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
[disabled]="!(hasChanges() | async)"
|
[disabled]="!(hasChanges() | async)"
|
||||||
(click)="discard()"><i
|
(click)="discard()"><i
|
||||||
class="fas fa-times"></i>
|
class="fas fa-times"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
(click)="reinstate()"><i
|
(click)="reinstate()"><i
|
||||||
class="fas fa-undo-alt"></i>
|
class="fas fa-undo-alt"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
<button class="btn btn-primary"
|
||||||
(click)="onSubmit()"><i
|
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||||
class="fas fa-save"></i>
|
(click)="onSubmit()"><i
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
class="fas fa-save"></i>
|
||||||
</button>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
</div>
|
</button>
|
||||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
</div>
|
||||||
<div *ngIf="contentSource" class="form-check mb-4">
|
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
<div *ngIf="contentSource" class="form-check mb-4">
|
||||||
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
|
||||||
</div>
|
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
<label class="form-check-label"
|
||||||
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||||
|
</div>
|
||||||
|
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
||||||
|
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
<div class="row">
|
||||||
[formId]="'collection-source-form-id'"
|
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||||
[formGroup]="formGroup"
|
[formId]="'collection-source-form-id'"
|
||||||
[formModel]="formModel"
|
[formGroup]="formGroup"
|
||||||
[formLayout]="formLayout"
|
[formModel]="formModel"
|
||||||
[displaySubmit]="false"
|
[formLayout]="formLayout"
|
||||||
[displayCancel]="false"
|
[displaySubmit]="false"
|
||||||
(dfChange)="onChange($event)"
|
[displayCancel]="false"
|
||||||
(submitForm)="onSubmit()"
|
(dfChange)="onChange($event)"
|
||||||
(cancel)="onCancel()"></ds-form>
|
(submitForm)="onSubmit()"
|
||||||
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
(cancel)="onCancel()"></ds-form>
|
||||||
<div class="d-inline-block float-right">
|
|
||||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
|
||||||
[disabled]="!(hasChanges() | async)"
|
|
||||||
(click)="discard()"><i
|
|
||||||
class="fas fa-times"></i>
|
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
|
||||||
(click)="reinstate()"><i
|
|
||||||
class="fas fa-undo-alt"></i>
|
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
|
||||||
(click)="onSubmit()"><i
|
|
||||||
class="fas fa-save"></i>
|
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-inline-block float-right ml-1">
|
||||||
|
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
|
[disabled]="!(hasChanges() | async)"
|
||||||
|
(click)="discard()"><i
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||||
|
(click)="onSubmit()"><i
|
||||||
|
class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-collection-source-controls
|
||||||
|
[isEnabled]="!(hasChanges()|async)"
|
||||||
|
[shouldShow]="contentSource?.harvestType !== harvestTypeNone"
|
||||||
|
[collection]="(collectionRD$ |async)?.payload"
|
||||||
|
>
|
||||||
|
</ds-collection-source-controls>
|
||||||
|
|
||||||
|
@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
|
|||||||
label: 'DSpace Intermediate Metadata',
|
label: 'DSpace Intermediate Metadata',
|
||||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
_links: { self: { href: 'contentsource-selflink' } }
|
||||||
});
|
});
|
||||||
fieldUpdate = {
|
fieldUpdate = {
|
||||||
field: contentSource,
|
field: contentSource,
|
||||||
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
|
|||||||
updateContentSource: observableOf(contentSource),
|
updateContentSource: observableOf(contentSource),
|
||||||
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
||||||
});
|
});
|
||||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
|
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||||
|
@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
|
|||||||
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
||||||
take(1)
|
take(1)
|
||||||
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
|
||||||
// Update harvester
|
// Update harvester
|
||||||
this.collectionRD$.pipe(
|
this.collectionRD$.pipe(
|
||||||
getFirstSucceededRemoteData(),
|
getFirstSucceededRemoteData(),
|
||||||
|
@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
|
|||||||
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
||||||
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
||||||
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
||||||
|
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module that contains all components related to the Edit Collection page administrator functionality
|
* Module that contains all components related to the Edit Collection page administrator functionality
|
||||||
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
|
|||||||
CollectionRolesComponent,
|
CollectionRolesComponent,
|
||||||
CollectionCurateComponent,
|
CollectionCurateComponent,
|
||||||
CollectionSourceComponent,
|
CollectionSourceComponent,
|
||||||
|
|
||||||
|
CollectionSourceControlsComponent,
|
||||||
CollectionAuthorizationsComponent
|
CollectionAuthorizationsComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
import { Subscription } from 'rxjs/internal/Subscription';
|
|
||||||
import { FindListOptions } from '../core/data/request.models';
|
import { FindListOptions } from '../core/data/request.models';
|
||||||
import { hasValue } from '../shared/empty.util';
|
import { hasValue } from '../shared/empty.util';
|
||||||
import { CommunityListService, FlatNode } from './community-list-service';
|
import { CommunityListService, FlatNode } from './community-list-service';
|
||||||
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
|
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
|
||||||
import { BehaviorSubject, Observable, } from 'rxjs';
|
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||||
import { finalize } from 'rxjs/operators';
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -42,7 +42,6 @@ import {
|
|||||||
UnsetUserAsIdleAction
|
UnsetUserAsIdleAction
|
||||||
} from './auth.actions';
|
} from './auth.actions';
|
||||||
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||||
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
|
|
||||||
import { RouteService } from '../services/route.service';
|
import { RouteService } from '../services/route.service';
|
||||||
import { EPersonDataService } from '../eperson/eperson-data.service';
|
import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||||
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
|
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
|
||||||
@@ -103,7 +102,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
public authenticate(user: string, password: string): Observable<AuthStatus> {
|
public authenticate(user: string, password: string): Observable<AuthStatus> {
|
||||||
// Attempt authenticating the user using the supplied credentials.
|
// Attempt authenticating the user using the supplied credentials.
|
||||||
const body = (`password=${Base64EncodeUrl(password)}&user=${Base64EncodeUrl(user)}`);
|
const body = (`password=${encodeURIComponent(password)}&user=${encodeURIComponent(user)}`);
|
||||||
const options: HttpOptions = Object.create({});
|
const options: HttpOptions = Object.create({});
|
||||||
let headers = new HttpHeaders();
|
let headers = new HttpHeaders();
|
||||||
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
|
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
3
src/app/core/cache/builders/link.service.ts
vendored
3
src/app/core/cache/builders/link.service.ts
vendored
@@ -10,8 +10,7 @@ import {
|
|||||||
LinkDefinition
|
LinkDefinition
|
||||||
} from './build-decorators';
|
} from './build-decorators';
|
||||||
import { RemoteData } from '../../data/remote-data';
|
import { RemoteData } from '../../data/remote-data';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { EMPTY, Observable } from 'rxjs';
|
||||||
import { EMPTY } from 'rxjs';
|
|
||||||
import { ResourceType } from '../../shared/resource-type';
|
import { ResourceType } from '../../shared/resource-type';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -17,7 +17,7 @@ import {
|
|||||||
createFailedRemoteDataObject$,
|
createFailedRemoteDataObject$,
|
||||||
createSuccessfulRemoteDataObject,
|
createSuccessfulRemoteDataObject,
|
||||||
createSuccessfulRemoteDataObject$
|
createSuccessfulRemoteDataObject$
|
||||||
} from 'src/app/shared/remote-data.utils';
|
} from '../../shared/remote-data.utils';
|
||||||
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
||||||
import { TestScheduler } from 'rxjs/testing';
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
@@ -27,12 +27,7 @@ import { CommunityDataService } from './community-data.service';
|
|||||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||||
import { PaginatedList } from './paginated-list.model';
|
import { PaginatedList } from './paginated-list.model';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import {
|
import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models';
|
||||||
ContentSourceRequest,
|
|
||||||
FindListOptions,
|
|
||||||
UpdateContentSourceRequest,
|
|
||||||
RestRequest
|
|
||||||
} from './request.models';
|
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
import { BitstreamDataService } from './bitstream-data.service';
|
import { BitstreamDataService } from './bitstream-data.service';
|
||||||
|
|
||||||
@@ -84,16 +79,48 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all collections the user is authorized to submit to
|
||||||
|
*
|
||||||
|
* @param query limit the returned collection to those with metadata values matching the query terms.
|
||||||
|
* @param entityType The entity type used to limit the returned collection
|
||||||
|
* @param options The [[FindListOptions]] object
|
||||||
|
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
|
||||||
|
* the response becomes stale
|
||||||
|
* @param linksToFollow The array of [[FollowLinkConfig]]
|
||||||
|
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||||
|
* collection list
|
||||||
|
*/
|
||||||
|
getAuthorizedCollectionByEntityType(
|
||||||
|
query: string,
|
||||||
|
entityType: string,
|
||||||
|
options: FindListOptions = {},
|
||||||
|
reRequestOnStale = true,
|
||||||
|
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||||
|
const searchHref = 'findSubmitAuthorizedByEntityType';
|
||||||
|
options = Object.assign({}, options, {
|
||||||
|
searchParams: [
|
||||||
|
new RequestParam('query', query),
|
||||||
|
new RequestParam('entityType', entityType)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
|
||||||
|
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all collections the user is authorized to submit to, by community
|
* Get all collections the user is authorized to submit to, by community
|
||||||
*
|
*
|
||||||
* @param communityId The community id
|
* @param communityId The community id
|
||||||
* @param query limit the returned collection to those with metadata values matching the query terms.
|
* @param query limit the returned collection to those with metadata values matching the query terms.
|
||||||
* @param options The [[FindListOptions]] object
|
* @param options The [[FindListOptions]] object
|
||||||
|
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||||
|
* requested after the response becomes stale
|
||||||
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||||
* collection list
|
* collection list
|
||||||
*/
|
*/
|
||||||
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
|
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||||
const searchHref = 'findSubmitAuthorizedByCommunity';
|
const searchHref = 'findSubmitAuthorizedByCommunity';
|
||||||
options = Object.assign({}, options, {
|
options = Object.assign({}, options, {
|
||||||
searchParams: [
|
searchParams: [
|
||||||
@@ -102,7 +129,38 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.searchBy(searchHref, options).pipe(
|
return this.searchBy(searchHref, options, reRequestOnStale).pipe(
|
||||||
|
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get all collections the user is authorized to submit to, by community and has the metadata
|
||||||
|
*
|
||||||
|
* @param communityId The community id
|
||||||
|
* @param entityType The entity type used to limit the returned collection
|
||||||
|
* @param options The [[FindListOptions]] object
|
||||||
|
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
|
||||||
|
* the response becomes stale
|
||||||
|
* @param linksToFollow The array of [[FollowLinkConfig]]
|
||||||
|
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||||
|
* collection list
|
||||||
|
*/
|
||||||
|
getAuthorizedCollectionByCommunityAndEntityType(
|
||||||
|
communityId: string,
|
||||||
|
entityType: string,
|
||||||
|
options: FindListOptions = {},
|
||||||
|
reRequestOnStale = true,
|
||||||
|
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||||
|
const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType';
|
||||||
|
const searchParams = [
|
||||||
|
new RequestParam('uuid', communityId),
|
||||||
|
new RequestParam('entityType', entityType)
|
||||||
|
];
|
||||||
|
|
||||||
|
options = Object.assign({}, options, {
|
||||||
|
searchParams: searchParams
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
|
||||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +196,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
* Get the collection's content harvester
|
* Get the collection's content harvester
|
||||||
* @param collectionId
|
* @param collectionId
|
||||||
*/
|
*/
|
||||||
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
|
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
|
||||||
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
||||||
isNotEmptyOperator(),
|
isNotEmptyOperator(),
|
||||||
take(1)
|
take(1)
|
||||||
@@ -146,7 +204,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
|
|
||||||
href$.subscribe((href: string) => {
|
href$.subscribe((href: string) => {
|
||||||
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
||||||
this.requestService.send(request, true);
|
this.requestService.send(request, useCachedVersionIfAvailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.rdbService.buildSingle<ContentSource>(href$);
|
return this.rdbService.buildSingle<ContentSource>(href$);
|
||||||
@@ -208,10 +266,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
|
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
|
||||||
* @param item Item we want the owning collection of
|
* @param item Item we want the owning collection of
|
||||||
*/
|
*/
|
||||||
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
||||||
return this.findByHref(item._links.owningCollection.href);
|
return this.findByHref(item._links.owningCollection.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of mapped collections for the given item.
|
||||||
|
* @param item Item for which the mapped collections should be retrieved.
|
||||||
|
* @param findListOptions Pagination and search options.
|
||||||
|
*/
|
||||||
|
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||||
|
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -10,13 +10,14 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { FindListOptions } from './request.models';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { switchMap, take, map } from 'rxjs/operators';
|
import { filter, map, switchMap, take } from 'rxjs/operators';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
|
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
|
||||||
import { PaginatedList } from './paginated-list.model';
|
import { PaginatedList } from './paginated-list.model';
|
||||||
import { ItemType } from '../shared/item-relationships/item-type.model';
|
import { ItemType } from '../shared/item-relationships/item-type.model';
|
||||||
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../shared/operators';
|
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
|
||||||
import { RelationshipTypeService } from './relationship-type.service';
|
import { RelationshipTypeService } from './relationship-type.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +57,7 @@ export class EntityTypeService extends DataService<ItemType> {
|
|||||||
/**
|
/**
|
||||||
* Check whether a given entity type is the left type of a given relationship type, as an observable boolean
|
* Check whether a given entity type is the left type of a given relationship type, as an observable boolean
|
||||||
* @param relationshipType the relationship type for which to check whether the given entity type is the left type
|
* @param relationshipType the relationship type for which to check whether the given entity type is the left type
|
||||||
* @param entityType the entity type for which to check whether it is the left type of the given relationship type
|
* @param itemType the entity type for which to check whether it is the left type of the given relationship type
|
||||||
*/
|
*/
|
||||||
isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
|
isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
|
||||||
|
|
||||||
@@ -67,6 +68,73 @@ export class EntityTypeService extends DataService<ItemType> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of entity types for which there is at least one collection in which the user is authorized to submit
|
||||||
|
*
|
||||||
|
* @param {FindListOptions} options
|
||||||
|
*/
|
||||||
|
getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
|
||||||
|
const searchHref = 'findAllByAuthorizedCollection';
|
||||||
|
|
||||||
|
return this.searchBy(searchHref, options).pipe(
|
||||||
|
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to verify if there are one or more entities available
|
||||||
|
*/
|
||||||
|
hasMoreThanOneAuthorized(): Observable<boolean> {
|
||||||
|
const findListOptions: FindListOptions = {
|
||||||
|
elementsPerPage: 2,
|
||||||
|
currentPage: 1
|
||||||
|
};
|
||||||
|
return this.getAllAuthorizedRelationshipType(findListOptions).pipe(
|
||||||
|
map((result: RemoteData<PaginatedList<ItemType>>) => {
|
||||||
|
let output: boolean;
|
||||||
|
if (result.payload) {
|
||||||
|
output = ( result.payload.page.length > 1 );
|
||||||
|
} else {
|
||||||
|
output = false;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It returns a list of entity types for which there is at least one collection
|
||||||
|
* in which the user is authorized to submit supported by at least one external data source provider
|
||||||
|
*
|
||||||
|
* @param {FindListOptions} options
|
||||||
|
*/
|
||||||
|
getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
|
||||||
|
const searchHref = 'findAllByAuthorizedExternalSource';
|
||||||
|
|
||||||
|
return this.searchBy(searchHref, options).pipe(
|
||||||
|
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to verify if there are one or more entities available. To use with external source import.
|
||||||
|
*/
|
||||||
|
hasMoreThanOneAuthorizedImport(): Observable<boolean> {
|
||||||
|
const findListOptions: FindListOptions = {
|
||||||
|
elementsPerPage: 2,
|
||||||
|
currentPage: 1
|
||||||
|
};
|
||||||
|
return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe(
|
||||||
|
map((result: RemoteData<PaginatedList<ItemType>>) => {
|
||||||
|
let output: boolean;
|
||||||
|
if (result.payload) {
|
||||||
|
output = ( result.payload.page.length > 1 );
|
||||||
|
} else {
|
||||||
|
output = false;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the allowed relationship types for an entity type
|
* Get the allowed relationship types for an entity type
|
||||||
* @param entityTypeId
|
* @param entityTypeId
|
||||||
|
@@ -7,7 +7,7 @@ import { PostRequest } from './request.models';
|
|||||||
import { Registration } from '../shared/registration.model';
|
import { Registration } from '../shared/registration.model';
|
||||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||||
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
||||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { TestScheduler } from 'rxjs/testing';
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
|
||||||
describe('EpersonRegistrationService', () => {
|
describe('EpersonRegistrationService', () => {
|
||||||
|
@@ -14,6 +14,7 @@ export enum FeatureID {
|
|||||||
IsCollectionAdmin = 'isCollectionAdmin',
|
IsCollectionAdmin = 'isCollectionAdmin',
|
||||||
IsCommunityAdmin = 'isCommunityAdmin',
|
IsCommunityAdmin = 'isCommunityAdmin',
|
||||||
CanDownload = 'canDownload',
|
CanDownload = 'canDownload',
|
||||||
|
CanRequestACopy = 'canRequestACopy',
|
||||||
CanManageVersions = 'canManageVersions',
|
CanManageVersions = 'canManageVersions',
|
||||||
CanManageBitstreamBundles = 'canManageBitstreamBundles',
|
CanManageBitstreamBundles = 'canManageBitstreamBundles',
|
||||||
CanManageRelationships = 'canManageRelationships',
|
CanManageRelationships = 'canManageRelationships',
|
||||||
@@ -21,4 +22,7 @@ export enum FeatureID {
|
|||||||
CanManagePolicies = 'canManagePolicies',
|
CanManagePolicies = 'canManagePolicies',
|
||||||
CanMakePrivate = 'canMakePrivate',
|
CanMakePrivate = 'canMakePrivate',
|
||||||
CanMove = 'canMove',
|
CanMove = 'canMove',
|
||||||
|
CanEditVersion = 'canEditVersion',
|
||||||
|
CanDeleteVersion = 'canDeleteVersion',
|
||||||
|
CanCreateVersion = 'canCreateVersion',
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,7 @@ import { FindListOptions } from './request.models';
|
|||||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
import { dataService } from '../cache/builders/build-decorators';
|
import { dataService } from '../cache/builders/build-decorators';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs';
|
||||||
import { PaginatedList } from './paginated-list.model';
|
import { PaginatedList } from './paginated-list.model';
|
||||||
import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type';
|
import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type';
|
||||||
import { LICENSE } from '../shared/license.resource-type';
|
import { LICENSE } from '../shared/license.resource-type';
|
||||||
|
@@ -31,7 +31,7 @@ describe('ItemDataService', () => {
|
|||||||
},
|
},
|
||||||
removeByHrefSubstring(href: string) {
|
removeByHrefSubstring(href: string) {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
},
|
||||||
}) as RequestService;
|
}) as RequestService;
|
||||||
const rdbService = getMockRemoteDataBuildService();
|
const rdbService = getMockRemoteDataBuildService();
|
||||||
|
|
||||||
@@ -184,4 +184,14 @@ describe('ItemDataService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when cache is invalidated', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service = initTestService();
|
||||||
|
});
|
||||||
|
it('should call setStaleByHrefSubstring', () => {
|
||||||
|
service.invalidateItemCache('uuid');
|
||||||
|
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('item/uuid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -59,6 +59,7 @@ export class ItemDataService extends DataService<Item> {
|
|||||||
* Get the endpoint for browsing items
|
* Get the endpoint for browsing items
|
||||||
* (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued')
|
* (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued')
|
||||||
* @param {FindListOptions} options
|
* @param {FindListOptions} options
|
||||||
|
* @param linkPath
|
||||||
* @returns {Observable<string>}
|
* @returns {Observable<string>}
|
||||||
*/
|
*/
|
||||||
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
|
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
|
||||||
@@ -287,4 +288,13 @@ export class ItemDataService extends DataService<Item> {
|
|||||||
switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`))
|
switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cache of the item
|
||||||
|
* @param itemUUID
|
||||||
|
*/
|
||||||
|
invalidateItemCache(itemUUID: string) {
|
||||||
|
this.requestService.setStaleByHrefSubstring('item/' + itemUUID);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
95
src/app/core/data/item-request-data.service.spec.ts
Normal file
95
src/app/core/data/item-request-data.service.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ItemRequestDataService } from './item-request-data.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
|
import { ItemRequest } from '../shared/item-request.model';
|
||||||
|
import { PostRequest } from './request.models';
|
||||||
|
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||||
|
import { RestRequestMethod } from './rest-request-method';
|
||||||
|
|
||||||
|
describe('ItemRequestDataService', () => {
|
||||||
|
let service: ItemRequestDataService;
|
||||||
|
|
||||||
|
let requestService: RequestService;
|
||||||
|
let rdbService: RemoteDataBuildService;
|
||||||
|
let halService: HALEndpointService;
|
||||||
|
|
||||||
|
const restApiEndpoint = 'rest/api/endpoint/';
|
||||||
|
const requestId = 'request-id';
|
||||||
|
let itemRequest: ItemRequest;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
itemRequest = Object.assign(new ItemRequest(), {
|
||||||
|
token: 'item-request-token',
|
||||||
|
});
|
||||||
|
requestService = jasmine.createSpyObj('requestService', {
|
||||||
|
generateRequestId: requestId,
|
||||||
|
send: '',
|
||||||
|
});
|
||||||
|
rdbService = jasmine.createSpyObj('rdbService', {
|
||||||
|
buildFromRequestUUID: createSuccessfulRemoteDataObject$(itemRequest),
|
||||||
|
});
|
||||||
|
halService = jasmine.createSpyObj('halService', {
|
||||||
|
getEndpoint: observableOf(restApiEndpoint),
|
||||||
|
});
|
||||||
|
|
||||||
|
service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestACopy', () => {
|
||||||
|
it('should send a POST request containing the provided item request', (done) => {
|
||||||
|
service.requestACopy(itemRequest).subscribe(() => {
|
||||||
|
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('grant', () => {
|
||||||
|
let email: RequestCopyEmail;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
email = new RequestCopyEmail('subject', 'message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send a PUT request containing the correct properties', (done) => {
|
||||||
|
service.grant(itemRequest.token, email, true).subscribe(() => {
|
||||||
|
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||||
|
method: RestRequestMethod.PUT,
|
||||||
|
body: JSON.stringify({
|
||||||
|
acceptRequest: true,
|
||||||
|
responseMessage: email.message,
|
||||||
|
subject: email.subject,
|
||||||
|
suggestOpenAccess: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deny', () => {
|
||||||
|
let email: RequestCopyEmail;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
email = new RequestCopyEmail('subject', 'message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send a PUT request containing the correct properties', (done) => {
|
||||||
|
service.deny(itemRequest.token, email).subscribe(() => {
|
||||||
|
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||||
|
method: RestRequestMethod.PUT,
|
||||||
|
body: JSON.stringify({
|
||||||
|
acceptRequest: false,
|
||||||
|
responseMessage: email.message,
|
||||||
|
subject: email.subject,
|
||||||
|
suggestOpenAccess: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
131
src/app/core/data/item-request-data.service.ts
Normal file
131
src/app/core/data/item-request-data.service.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { distinctUntilChanged, filter, find, map } from 'rxjs/operators';
|
||||||
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators';
|
||||||
|
import { RemoteData } from './remote-data';
|
||||||
|
import { PostRequest, PutRequest } from './request.models';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { ItemRequest } from '../shared/item-request.model';
|
||||||
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
|
import { DataService } from './data.service';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { CoreState } from '../core.reducers';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||||
|
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||||
|
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint
|
||||||
|
*/
|
||||||
|
@Injectable(
|
||||||
|
{
|
||||||
|
providedIn: 'root',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
export class ItemRequestDataService extends DataService<ItemRequest> {
|
||||||
|
|
||||||
|
protected linkPath = 'itemrequests';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected requestService: RequestService,
|
||||||
|
protected rdbService: RemoteDataBuildService,
|
||||||
|
protected store: Store<CoreState>,
|
||||||
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected halService: HALEndpointService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected http: HttpClient,
|
||||||
|
protected comparator: DefaultChangeAnalyzer<ItemRequest>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemRequestEndpoint(): Observable<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the endpoint for an {@link ItemRequest} by their token
|
||||||
|
* @param token
|
||||||
|
*/
|
||||||
|
getItemRequestEndpointByToken(token: string): Observable<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
|
filter((href: string) => isNotEmpty(href)),
|
||||||
|
map((href: string) => `${href}/${token}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a copy of an item
|
||||||
|
* @param itemRequest
|
||||||
|
*/
|
||||||
|
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
|
const href$ = this.getItemRequestEndpoint();
|
||||||
|
|
||||||
|
href$.pipe(
|
||||||
|
find((href: string) => hasValue(href)),
|
||||||
|
map((href: string) => {
|
||||||
|
const request = new PostRequest(requestId, href, itemRequest);
|
||||||
|
this.requestService.send(request);
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
return this.rdbService.buildFromRequestUUID<ItemRequest>(requestId).pipe(
|
||||||
|
getFirstCompletedRemoteData()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deny the request of an item
|
||||||
|
* @param token Token of the {@link ItemRequest}
|
||||||
|
* @param email Email to send back to the user requesting the item
|
||||||
|
*/
|
||||||
|
deny(token: string, email: RequestCopyEmail): Observable<RemoteData<ItemRequest>> {
|
||||||
|
return this.process(token, email, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant the request of an item
|
||||||
|
* @param token Token of the {@link ItemRequest}
|
||||||
|
* @param email Email to send back to the user requesting the item
|
||||||
|
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||||
|
*/
|
||||||
|
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||||
|
return this.process(token, email, true, suggestOpenAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the request of an item
|
||||||
|
* @param token Token of the {@link ItemRequest}
|
||||||
|
* @param email Email to send back to the user requesting the item
|
||||||
|
* @param grant Grant or deny the request (true = grant, false = deny)
|
||||||
|
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||||
|
*/
|
||||||
|
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
|
this.getItemRequestEndpointByToken(token).pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((endpointURL: string) => {
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.append('Content-Type', 'application/json');
|
||||||
|
options.headers = headers;
|
||||||
|
return new PutRequest(requestId, endpointURL, JSON.stringify({
|
||||||
|
acceptRequest: grant,
|
||||||
|
responseMessage: email.message,
|
||||||
|
subject: email.subject,
|
||||||
|
suggestOpenAccess,
|
||||||
|
}), options);
|
||||||
|
}),
|
||||||
|
sendRequest(this.requestService)).subscribe();
|
||||||
|
|
||||||
|
return this.rdbService.buildFromRequestUUID(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user