diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f4a11acc8d..77580207245 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,9 +16,9 @@ jobs: DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_SSL: false - # When Chrome version is specified, we pin to a specific version of Chrome & ChromeDriver - # Comment this out to use the latest release of both. - CHROME_VERSION: "90.0.4430.212-1" + # When Chrome version is specified, we pin to a specific version of Chrome + # Comment this out to use the latest release + #CHROME_VERSION: "90.0.4430.212-1" strategy: # Create a matrix of Node versions to test against (in parallel) matrix: @@ -66,12 +66,6 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: ${{ runner.os }}-yarn- - - name: Install latest ChromeDriver compatible with installed Chrome - # needs to be npm, the --detect_chromedriver_version flag doesn't work with yarn global - run: | - npm install -g chromedriver --detect_chromedriver_version - chromedriver -v - - name: Install Yarn dependencies run: yarn install --frozen-lockfile @@ -99,23 +93,40 @@ jobs: docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli docker container ls - # Wait until the REST API returns a 200 response (or for a max of 30 seconds) - # https://github.com/nev7n/wait_for_response - - name: Wait for DSpace REST Backend to be ready (for e2e tests) - uses: nev7n/wait_for_response@v1 - with: - # We use the 'sites' endpoint to also ensure the database is ready - url: 'http://localhost:8080/server/api/core/sites' - responseCode: 200 - timeout: 30000 - - - name: Get DSpace REST Backend info/properties - run: curl http://localhost:8080/server/api - + # Run integration tests via Cypress.io + # https://github.com/cypress-io/github-action + # (NOTE: to run these e2e tests locally, just use 'ng e2e') - name: Run e2e tests (integration tests) - run: | - chromedriver --url-base='/wd/hub' --port=4444 & - yarn run e2e:ci + uses: cypress-io/github-action@v2 + with: + # Run tests in Chrome, headless mode + browser: chrome + headless: true + # Start app before running tests (will be stopped automatically after tests finish) + start: yarn run serve:ssr + # Wait for backend & frontend to be available + # NOTE: We use the 'sites' REST endpoint to also ensure the database is ready + wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000 + # Wait for 2 mins max for everything to respond + wait-on-timeout: 120 + + # Cypress always creates a video of all e2e tests (whether they succeeded or failed) + # Save those in an Artifact + - name: Upload e2e test videos to Artifacts + uses: actions/upload-artifact@v2 + if: always() + with: + name: e2e-test-videos + path: cypress/videos + + # If e2e tests fail, Cypress creates a screenshot of what happened + # Save those in an Artifact + - name: Upload e2e test failure screenshots to Artifacts + uses: actions/upload-artifact@v2 + if: failure() + with: + name: e2e-test-screenshots + path: cypress/screenshots # Start up the app with SSR enabled (run in background) - name: Start app in SSR (server-side rendering) mode diff --git a/README.md b/README.md index d39dd9982b6..abc1ab70270 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,17 @@ Table of Contents - [Introduction to the technology](#introduction-to-the-technology) - [Requirements](#requirements) - [Installing](#installing) - - [Configuring](#configuring) + - [Configuring](#configuring) - [Running the app](#running-the-app) - - [Running in production mode](#running-in-production-mode) + - [Running in production mode](#running-in-production-mode) - [Deploy](#deploy) - [Running the application with Docker](#running-the-application-with-docker) - [Cleaning](#cleaning) - [Testing](#testing) - [Test a Pull Request](#test-a-pull-request) + - [Unit Tests](#unit-tests) + - [E2E Tests](#e2e-tests) + - [Writing E2E Tests](#writing-e2e-tests) - [Documentation](#documentation) - [Other commands](#other-commands) - [Recommended Editors/IDEs](#recommended-editorsides) @@ -82,9 +85,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. - 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. @@ -103,7 +106,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.: -```bash +```bash export DSPACE_HOST=api7.dspace.org ``` @@ -118,7 +121,7 @@ To use environment variables in a UI component, use: 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 @@ -187,34 +190,66 @@ Once you have tested the Pull Request, please add a comment and/or approval to t ### Unit Tests -Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`. +Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/). + +You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`. The default browser is Google Chrome. -Place your tests in the same location of the application source code files that they test. +Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts` -and run: `yarn run test` +and run: `yarn test` -### E2E test +If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging -E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file:`./protractor.conf.js` Protractor is installed as 'local' as a dev dependency. +### E2E Tests -If you are going to use a remote test enviroment you need to edit the './e2e//protractor.conf.js'. Follow the instructions you will find inside it. +E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Configuration for cypress can be found in the `cypress.json` file in the root directory. -The default browser is Google Chrome. +The test files can be found in the `./cypress/integration/` folder. + +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). + +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. -Place your tests at the following path: `./e2e` +#### Writing E2E Tests -and run: `ng e2e` +All E2E tests must be created under the `./cypress/integration/` folder, and must end in `.spec.ts`. Subfolders are allowed. -### Continuous Integration (CI) Test +* 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. -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. +_Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can help prompt/autocomplete your Cypress commands._ + +More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress. + +### Learning how to build tests + +See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips. Documentation -------------- -See [`./docs`](docs) for further documentation. +Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/ + +Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase. ### Building code documentation @@ -237,8 +272,6 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've - Free - [Visual Studio Code](https://code.visualstudio.com/) - [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) - - [Atom](https://atom.io/) - - [TypeScript plugin](https://atom.io/packages/atom-typescript) - Paid - [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/) - [Sublime Text](http://www.sublimetext.com/3) @@ -260,95 +293,43 @@ dspace-angular │   ├── environment.default.js * Default configuration files │   └── environment.test.js * Test configuration files ├── docs * Folder for documentation -├── e2e * Folder for e2e test files -│   ├── app.e2e-spec.ts * -│   ├── app.po.ts * -│   ├── pagenotfound * -│   │   ├── pagenotfound.e2e-spec.ts * -│   │   └── pagenotfound.po.ts * +├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests +│   ├── integration * Folder for e2e/integration test files +│   ├── fixtures * Folder for any fixtures needed by e2e tests +│   ├── plugins * Folder for Cypress plugins (if any) +│   ├── support * Folder for global e2e test actions/commands (run for all tests) │   └── tsconfig.json * TypeScript configuration file for e2e tests ├── karma.conf.js * Karma configuration file for Unit Test ├── nodemon.json * Nodemon (https://nodemon.io/) configuration ├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. ├── postcss.config.js * PostCSS (http://postcss.org/) configuration file -├── protractor.conf.js * -├── resources * Folder for static resources -│   ├── data * Folder for static data -│   │   └── en * Folder for i18n English data -│   ├── i18n * Folder for i18n translations -│   │   └── en.json * i18n translations for English -│   └── images * Folder for images -│   ├── dspace-logo-old.png * -│   ├── dspace-logo.png * -│   └── favicon.ico * -├── rollup.config.js * Rollup (http://rollupjs.org/) configuration -├── spec-bundle.js * ├── src * The source of the application -│   ├── app * -│   │   ├── app-routing.module.ts * -│   │   ├── app.component.html * -│   │   ├── app.component.scss * -│   │   ├── app.component.spec.ts * -│   │   ├── app.component.ts * -│   │   ├── app.effects.ts * -│   │   ├── app.module.ts * -│   │   ├── app.reducer.ts * -│   │   ├── browser-app.module.ts * The root module for the client -│   │   ├── +collection-page * Lazily loaded route for collection module -│   │   ├── +community-page * Lazily loaded route for community module -│   │   ├── core * -│   │   ├── header * -│   │   ├── +home * Lazily loaded route for home module -│   │   ├── +item-page * Lazily loaded route for item module -│   │   ├── object-list * -│   │   ├── pagenotfound * -│   │   ├── server-app.module.ts * The root module for the server -│   │   ├── shared * -│   │   ├── store.actions.ts * -│   │   ├── store.effects.ts * -│   │   ├── thumbnail * -│   │   └── typings.d.ts * File that allows you to add custom typings for libraries without TypeScript support +│   ├── app * The source code of the application, subdivided by module/page. +│   ├── assets * Folder for static resources +│   │   ├── fonts * Folder for fonts +│   │   ├── i18n * Folder for i18n translations +│   | └── en.json5 * i18n translations for English +│   │   └── images * Folder for images │   ├── backend * Folder containing a mock of the REST API, hosted by the express server -│   │   ├── api.ts * -│   │   ├── cache.ts * -│   │   ├── data * -│   │   └── db.ts * │   ├── config * -│   │   ├── cache-config.interface.ts * -│   │   ├── config.interface.ts * -│   │   ├── global-config.interface.ts * -│   │   ├── server-config.interface.ts * -│   │   └── universal-config.interface.ts * -│   ├── config.ts * File that loads environmental and shareable settings and makes them available to app components │   ├── index.csr.html * The index file for client side rendering fallback │   ├── index.html * The index file │   ├── main.browser.ts * The bootstrap file for the client │   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server +│   ├── robots.txt * The robots.txt file │   ├── modules * -│   │   ├── cookies * -│   │   ├── data-loader * -│   │   ├── transfer-http * -│   │   ├── transfer-state * -│   │   ├── transfer-store * -│   │   └── translate-universal-loader.ts * -│   ├── routes.ts * The routes file for the server │   ├── styles * Folder containing global styles -│   │   ├── _mixins.scss * -│   │   └── variables.scss * Global sass variables file -│   ├── tsconfig.browser.json * TypeScript config for the client build -│   ├── tsconfig.server.json * TypeScript config for the server build -│   └── tsconfig.test.json * TypeScript config for the test build +│   └── themes * Folder containing available themes +│      ├── custom * Template folder for creating a custom theme +│      └── dspace * Default 'dspace' theme ├── tsconfig.json * TypeScript config ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── typedoc.json * TYPEDOC configuration ├── webpack * Webpack (https://webpack.github.io/) config directory -│   ├── webpack.aot.js * Webpack (https://webpack.github.io/) config for AoT build -│   ├── webpack.client.js * Webpack (https://webpack.github.io/) config for client build -│   ├── webpack.common.js * -│   ├── webpack.prod.js * Webpack (https://webpack.github.io/) config for production build -│   ├── webpack.server.js * Webpack (https://webpack.github.io/) config for server build -│   └── webpack.test.js * Webpack (https://webpack.github.io/) config for test build -├── webpack.config.ts * +│   ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for client build +│   ├── webpack.common.ts * +│   ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for production build +│   └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build └── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) ``` diff --git a/angular.json b/angular.json index cfc83177cf7..b004a5aa18b 100644 --- a/angular.json +++ b/angular.json @@ -151,7 +151,7 @@ "tsConfig": [ "tsconfig.app.json", "tsconfig.spec.json", - "e2e/tsconfig.json" + "cypress/tsconfig.json" ], "exclude": [ "**/node_modules/**" @@ -159,10 +159,11 @@ } }, "e2e": { - "builder": "@angular-devkit/build-angular:protractor", + "builder": "@cypress/schematic:cypress", "options": { - "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "dspace-angular:serve" + "devServerTarget": "dspace-angular:serve", + "watch": true, + "headless": false }, "configurations": { "production": { @@ -219,6 +220,24 @@ "configurations": { "production": {} } + }, + "cypress-run": { + "builder": "@cypress/schematic:cypress", + "options": { + "devServerTarget": "dspace-angular:serve" + }, + "configurations": { + "production": { + "devServerTarget": "dspace-angular:serve:production" + } + } + }, + "cypress-open": { + "builder": "@cypress/schematic:cypress", + "options": { + "watch": true, + "headless": false + } } } } @@ -227,4 +246,4 @@ "cli": { "analytics": false } -} \ No newline at end of file +} diff --git a/cypress.json b/cypress.json new file mode 100644 index 00000000000..cded267c482 --- /dev/null +++ b/cypress.json @@ -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" +} \ No newline at end of file diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000000..02e4254378e --- /dev/null +++ b/cypress/fixtures/example.json @@ -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" +} diff --git a/cypress/integration/breadcrumbs.spec.ts b/cypress/integration/breadcrumbs.spec.ts new file mode 100644 index 00000000000..a74de1660c0 --- /dev/null +++ b/cypress/integration/breadcrumbs.spec.ts @@ -0,0 +1,22 @@ +import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +xdescribe('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 for accessibility + testA11y('ds-breadcrumbs', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/browse-by-author.spec.ts b/cypress/integration/browse-by-author.spec.ts new file mode 100644 index 00000000000..07c20ad7c91 --- /dev/null +++ b/cypress/integration/browse-by-author.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-metadata-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-metadata-page'); + }); +}); diff --git a/cypress/integration/browse-by-dateissued.spec.ts b/cypress/integration/browse-by-dateissued.spec.ts new file mode 100644 index 00000000000..4d22420227c --- /dev/null +++ b/cypress/integration/browse-by-dateissued.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-date-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-date-page'); + }); +}); diff --git a/cypress/integration/browse-by-subject.spec.ts b/cypress/integration/browse-by-subject.spec.ts new file mode 100644 index 00000000000..89b791f03c4 --- /dev/null +++ b/cypress/integration/browse-by-subject.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-metadata-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-metadata-page'); + }); +}); diff --git a/cypress/integration/browse-by-title.spec.ts b/cypress/integration/browse-by-title.spec.ts new file mode 100644 index 00000000000..e4e027586a8 --- /dev/null +++ b/cypress/integration/browse-by-title.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-title-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-title-page'); + }); +}); diff --git a/cypress/integration/collection-page.spec.ts b/cypress/integration/collection-page.spec.ts new file mode 100644 index 00000000000..a0140d8faf2 --- /dev/null +++ b/cypress/integration/collection-page.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-collection-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-collection-page'); + }); +}); diff --git a/cypress/integration/collection-statistics.spec.ts b/cypress/integration/collection-statistics.spec.ts new file mode 100644 index 00000000000..1532de00175 --- /dev/null +++ b/cypress/integration/collection-statistics.spec.ts @@ -0,0 +1,32 @@ +import { TEST_COLLECTION } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +xdescribe('Collection Statistics Page', () => { + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION; + + it('should load if you click on "Statistics" from a Collection page', () => { + cy.visit('/collections/' + TEST_COLLECTION); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-collection-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-collection-statistics-page'); + }); +}); diff --git a/cypress/integration/community-list.spec.ts b/cypress/integration/community-list.spec.ts new file mode 100644 index 00000000000..9ff28bf2743 --- /dev/null +++ b/cypress/integration/community-list.spec.ts @@ -0,0 +1,26 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +describe('Community List Page', () => { + + it('should pass accessibility tests', () => { + cy.visit('/community-list'); + + // 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 for accessibility issues + // Disable heading-order checks until it is fixed + testA11y('ds-community-list-page', + { + rules: { + 'heading-order': { enabled: false }, + 'button-name': { enabled: false }, + } + } as Options + ); + }); +}); diff --git a/cypress/integration/community-page.spec.ts b/cypress/integration/community-page.spec.ts new file mode 100644 index 00000000000..fec570f8ec8 --- /dev/null +++ b/cypress/integration/community-page.spec.ts @@ -0,0 +1,22 @@ +import { TEST_COMMUNITY } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +describe('Community Page', () => { + + it('should pass accessibility tests', () => { + cy.visit('/communities/' + TEST_COMMUNITY); + + // tag must be loaded + cy.get('ds-community-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-community-page', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/community-statistics.spec.ts b/cypress/integration/community-statistics.spec.ts new file mode 100644 index 00000000000..ddd447cf213 --- /dev/null +++ b/cypress/integration/community-statistics.spec.ts @@ -0,0 +1,32 @@ +import { TEST_COMMUNITY } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +xdescribe('Community Statistics Page', () => { + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY; + + it('should load if you click on "Statistics" from a Community page', () => { + cy.visit('/communities/' + TEST_COMMUNITY); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-community-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-community-statistics-page'); + }); +}); diff --git a/cypress/integration/footer.spec.ts b/cypress/integration/footer.spec.ts new file mode 100644 index 00000000000..b0c9d15756f --- /dev/null +++ b/cypress/integration/footer.spec.ts @@ -0,0 +1,20 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +describe('Footer', () => { + it('should pass accessibility tests', () => { + cy.visit('/'); + + // Footer must first be visible + cy.get('ds-footer').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-footer', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/header.spec.ts b/cypress/integration/header.spec.ts new file mode 100644 index 00000000000..236208db686 --- /dev/null +++ b/cypress/integration/header.spec.ts @@ -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 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 + ], + }); + }); +}); diff --git a/cypress/integration/homepage-statistics.spec.ts b/cypress/integration/homepage-statistics.spec.ts new file mode 100644 index 00000000000..a24af59f053 --- /dev/null +++ b/cypress/integration/homepage-statistics.spec.ts @@ -0,0 +1,19 @@ +import { testA11y } from 'cypress/support/utils'; + +xdescribe('Site Statistics Page', () => { + it('should load if you click on "Statistics" from homepage', () => { + cy.visit('/'); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', '/statistics'); + }); + + it('should pass accessibility tests', () => { + cy.visit('/statistics'); + + // tag must be loaded + cy.get('ds-site-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-site-statistics-page'); + }); +}); diff --git a/cypress/integration/homepage.spec.ts b/cypress/integration/homepage.spec.ts new file mode 100644 index 00000000000..12a29d3dddf --- /dev/null +++ b/cypress/integration/homepage.spec.ts @@ -0,0 +1,39 @@ +import { testA11y } from 'cypress/support/utils'; +import { Options } from 'cypress-axe'; + +describe('Homepage', () => { + beforeEach(() => { + // All tests start with visiting homepage + cy.visit('/'); + }); + + it('should display translated title "DSpace Cris Angular :: Home"', () => { + cy.title().should('eq', 'DSpace Cris Angular :: Home'); + }); + + it('should contain a news section', () => { + cy.get('ds-home-news').should('be.visible'); + }); + + xit('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 for accessibility issues + testA11y('ds-home-page', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/item-page.spec.ts b/cypress/integration/item-page.spec.ts new file mode 100644 index 00000000000..7104a731955 --- /dev/null +++ b/cypress/integration/item-page.spec.ts @@ -0,0 +1,31 @@ +import { Options } from 'cypress-axe'; +import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +xdescribe('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); + + // tag must be loaded + cy.get('ds-item-page').should('exist'); + + // Analyze for accessibility issues + // Disable heading-order checks until it is fixed + testA11y('ds-item-page', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/item-statistics.spec.ts b/cypress/integration/item-statistics.spec.ts new file mode 100644 index 00000000000..71269dc9c58 --- /dev/null +++ b/cypress/integration/item-statistics.spec.ts @@ -0,0 +1,38 @@ +import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +xdescribe('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); + + // tag must be loaded + cy.get('ds-item-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-item-statistics-page'); + }); +}); diff --git a/cypress/integration/pagenotfound.spec.ts b/cypress/integration/pagenotfound.spec.ts new file mode 100644 index 00000000000..48520bcaa32 --- /dev/null +++ b/cypress/integration/pagenotfound.spec.ts @@ -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'); + }); + +}); diff --git a/cypress/integration/search-navbar.spec.ts b/cypress/integration/search-navbar.spec.ts new file mode 100644 index 00000000000..19a3d56ed4c --- /dev/null +++ b/cypress/integration/search-navbar.spec.ts @@ -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'); + }); +}); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts new file mode 100644 index 00000000000..73bad5f3998 --- /dev/null +++ b/cypress/integration/search-page.spec.ts @@ -0,0 +1,69 @@ +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'); + + // 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 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(); + + // tag must be loaded + cy.get('ds-search-page').should('exist'); + + // Analyze 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 + );*/ + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts new file mode 100644 index 00000000000..c6eb8742322 --- /dev/null +++ b/cypress/plugins/index.ts @@ -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; + } + }); +}; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 00000000000..af1f44a0fcb --- /dev/null +++ b/cypress/support/commands.ts @@ -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 { +// 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) => { ... }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 00000000000..e8b10b9cfbd --- /dev/null +++ b/cypress/support/index.ts @@ -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'; diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts new file mode 100644 index 00000000000..96575969e85 --- /dev/null +++ b/cypress/support/utils.ts @@ -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); +}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000000..58083003cda --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "**/*.ts" + ], + "compilerOptions": { + "types": [ + "cypress", + "cypress-axe", + "node" + ] + } +} \ No newline at end of file diff --git a/e2e/protractor-ci.conf.js b/e2e/protractor-ci.conf.js deleted file mode 100644 index 0cfc1f9eaf9..00000000000 --- a/e2e/protractor-ci.conf.js +++ /dev/null @@ -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; diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js deleted file mode 100644 index 93bf7f3301c..00000000000 --- a/e2e/protractor.conf.js +++ /dev/null @@ -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' - } - })); - } -}; diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts index c1ad342ee38..e69de29bb2d 100644 --- a/e2e/src/app.e2e-spec.ts +++ b/e2e/src/app.e2e-spec.ts @@ -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 Cris Angular :: Home"', () => { - page.navigateTo(); - page.waitUntilNotLoading(); - expect(page.getPageTitleText()).toEqual('DSpace Cris Angular :: Home'); - }); - - it('should contain a news section', () => { - page.navigateTo(); - page.waitUntilNotLoading(); - const text = page.getHomePageNewsText(); - expect(text).toBeDefined(); - }); -}); diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts deleted file mode 100644 index dc554065feb..00000000000 --- a/e2e/src/app.po.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { browser, by, element, promise, protractor } 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 { - const loading = element(by.css('.loader')); - const EC = protractor.ExpectedConditions; - const notLoading = EC.not(EC.presenceOf(loading)); - return browser.wait(notLoading, 10000); - } -} diff --git a/e2e/src/item-statistics/item-statistics.e2e-spec.ts b/e2e/src/item-statistics/item-statistics.e2e-spec.ts deleted file mode 100644 index fd2424ed3e0..00000000000 --- a/e2e/src/item-statistics/item-statistics.e2e-spec.ts +++ /dev/null @@ -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'; - -xdescribe('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(page.elementTagExists('ds-item-page')).toEqual(true); - expect(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(page.elementTagExists('ds-item-statistics-page')).toEqual(true); - expect(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()); - }); -}); diff --git a/e2e/src/item-statistics/item-statistics.po.ts b/e2e/src/item-statistics/item-statistics.po.ts deleted file mode 100644 index 92666bcf351..00000000000 --- a/e2e/src/item-statistics/item-statistics.po.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { browser, by, element } 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(); - } -} diff --git a/e2e/src/pagenotfound/pagenotfound.e2e-spec.ts b/e2e/src/pagenotfound/pagenotfound.e2e-spec.ts deleted file mode 100644 index bad2036c3ae..00000000000 --- a/e2e/src/pagenotfound/pagenotfound.e2e-spec.ts +++ /dev/null @@ -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(page.elementTagExists('ds-pagenotfound')).toEqual(true); - }); - - it('should not contain element ds-pagenotfound when navigating to existing page', () => { - page.navigateToExistingPage(); - expect(page.elementTagExists('ds-pagenotfound')).toEqual(false); - }); -}); diff --git a/e2e/src/pagenotfound/pagenotfound.po.ts b/e2e/src/pagenotfound/pagenotfound.po.ts deleted file mode 100644 index a3c02ab644a..00000000000 --- a/e2e/src/pagenotfound/pagenotfound.po.ts +++ /dev/null @@ -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(); - } - -} diff --git a/e2e/src/search-navbar/search-navbar.e2e-spec.ts b/e2e/src/search-navbar/search-navbar.e2e-spec.ts deleted file mode 100644 index b60f71919d5..00000000000 --- a/e2e/src/search-navbar/search-navbar.e2e-spec.ts +++ /dev/null @@ -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; - } - -}); diff --git a/e2e/src/search-navbar/search-navbar.po.ts b/e2e/src/search-navbar/search-navbar.po.ts deleted file mode 100644 index c1ac817fd22..00000000000 --- a/e2e/src/search-navbar/search-navbar.po.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/e2e/src/search-page/search-page.e2e-spec.ts b/e2e/src/search-page/search-page.e2e-spec.ts deleted file mode 100644 index f54fc9b6627..00000000000 --- a/e2e/src/search-page/search-page.e2e-spec.ts +++ /dev/null @@ -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(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(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; - }); - }); - }); -}); diff --git a/e2e/src/search-page/search-page.po.ts b/e2e/src/search-page/search-page.po.ts deleted file mode 100644 index 83a66a848be..00000000000 --- a/e2e/src/search-page/search-page.po.ts +++ /dev/null @@ -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 { - const scopeSelect = element(by.css('#search-form select')); - browser.wait(protractor.ExpectedConditions.presenceOf(scopeSelect), 10000); - return scopeSelect.getAttribute('value'); - } - - getCurrentQuery(): promise.Promise { - 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 { - 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 { - const loading = element(by.css('.loader')); - const EC = protractor.ExpectedConditions; - const notLoading = EC.not(EC.presenceOf(loading)); - return browser.wait(notLoading, 10000); - } -} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json deleted file mode 100644 index fdb29acf69c..00000000000 --- a/e2e/tsconfig.json +++ /dev/null @@ -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" - ] - } -} diff --git a/package.json b/package.json index dea751cd818..12576d06ad0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "2021.02.00-SNAPSHOT", + "version": "2021.02.01-SNAPSHOT", "scripts": { "ng": "ng", "config:dev": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev", @@ -8,6 +8,7 @@ "config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts", "config:test:watch": "nodemon --config mock-nodemon.json", "config:dev:watch": "nodemon", + "config:check:rest": "yarn run config:prod && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", "prestart:dev": "yarn run config:dev", "prebuild": "yarn run config:dev", "pretest": "yarn run config:test", @@ -15,11 +16,11 @@ "pretest:headless": "yarn run config:test", "prebuild:prod": "yarn run config:prod", "pree2e": "yarn run config:prod", - "pree2e:ci": "yarn run config:prod", "start": "yarn run start:prod", "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "start:dev": "npm-run-all --parallel config:dev:watch serve", "start:prod": "yarn run build:prod && yarn run serve:ssr", + "start:mirador:prod": "yarn run build:mirador && yarn run start:prod", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build", "build:stats": "ng build --stats-json", @@ -33,7 +34,6 @@ "lint": "ng lint", "lint-fix": "npm run ng-high-memory -- lint --fix=true", "e2e": "ng e2e", - "e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js", "compile:server": "webpack --config webpack.server.config.js --progress --color", "serve:ssr": "node dist/server", "clean:coverage": "rimraf coverage", @@ -47,7 +47,11 @@ "clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node", "clean:env": "rimraf src/environments/environment.ts", "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts", + "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", "deploy": "pm2 start dist/server.js", "predeploy": "npm run build:prod", "preundeploy": "pm2 stop dist/server.js", @@ -114,6 +118,9 @@ "jsonschema": "1.4.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.10", + "mirador": "^3.0.0", + "mirador-dl-plugin": "^0.13.0", + "mirador-share-plugin": "^0.10.0", "moment": "^2.29.1", "morgan": "^1.10.0", "ng-mocks": "10.5.4", @@ -128,6 +135,8 @@ "nouislider": "^14.6.3", "pem": "1.14.4", "postcss-cli": "^8.3.0", + "react": "^16.14.0", + "react-dom": "^16.14.0", "reflect-metadata": "^0.1.13", "rxjs": "^6.6.3", "rxjs-spy": "^7.5.3", @@ -143,6 +152,7 @@ "@angular/cli": "~10.2.0", "@angular/compiler-cli": "~10.2.3", "@angular/language-service": "~10.2.3", + "@cypress/schematic": "^1.5.0", "@fortawesome/fontawesome-free": "^5.5.0", "@ngrx/store-devtools": "^10.0.1", "@ngtools/webpack": "~10.2.0", @@ -155,14 +165,18 @@ "@types/js-cookie": "2.2.6", "@types/lodash": "^4.14.165", "@types/node": "^14.14.9", + "axe-core": "^4.3.3", "codelyzer": "^6.0.1", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^6.4.1", "css-loader": "3.4.0", "cssnano": "^4.1.10", + "cypress": "8.6.0", + "cypress-axe": "^0.13.0", "deep-freeze": "0.0.1", "dotenv": "^8.2.0", "fork-ts-checker-webpack-plugin": "^6.0.3", + "html-loader": "^1.3.2", "html-webpack-plugin": "^4.5.0", "http-proxy-middleware": "^1.0.5", "jasmine-core": "^3.6.0", diff --git a/scripts/merge-i18n-files.ts b/scripts/merge-i18n-files.ts new file mode 100644 index 00000000000..915bc4bf592 --- /dev/null +++ b/scripts/merge-i18n-files.ts @@ -0,0 +1,100 @@ +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 when running script on all language files', projectRoot(LANGUAGE_FILES_LOCATION)) + .option('-s, --source-dir ', 'source dir of transalations to be merged') + .usage('(-s [-d ])') + .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(); +} diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts new file mode 100644 index 00000000000..b12a9929c2d --- /dev/null +++ b/scripts/test-rest.ts @@ -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}`); + } +} diff --git a/server.ts b/server.ts index 79a489628f3..6a031be6caa 100644 --- a/server.ts +++ b/server.ts @@ -20,6 +20,7 @@ import 'reflect-metadata'; import 'rxjs'; import * as fs from 'fs'; +import { existsSync } from 'fs'; import * as pem from 'pem'; import * as https from 'https'; import * as morgan from 'morgan'; @@ -29,11 +30,10 @@ import * as compression from 'compression'; import { join } from 'path'; import { enableProdMode } from '@angular/core'; -import { existsSync } from 'fs'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasValue, hasNoValue } from './src/app/shared/empty.util'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; import { APP_BASE_HREF } from '@angular/common'; import { UIServerConfig } from './src/config/ui-server-config.interface'; @@ -41,6 +41,8 @@ import { UIServerConfig } from './src/config/ui-server-config.interface'; * Set path for the browser application's dist folder */ const DIST_FOLDER = join(process.cwd(), 'dist/browser'); +// Set path fir IIIF viewer. +const IIIF_VIEWER = join(process.cwd(), 'dist/iiif'); const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index'; @@ -134,6 +136,10 @@ export function app() { * Serve static resources (images, i18n messages, …) */ 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 server.get('*', ngApp); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 9411e61b275..9ecd696e9d6 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -56,15 +56,17 @@
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
- - + + + - - + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}}{{group.id}}{{group.name}}{{(group.object | async)?.payload?.name}}
diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 3133cde556b..1b171c3c074 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -2,7 +2,7 @@ import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { BrowserModule, By } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -29,6 +29,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestService } from '../../../core/data/request.service'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; describe('EPersonFormComponent', () => { @@ -102,12 +103,78 @@ describe('EPersonFormComponent', () => { } }); return createSuccessfulRemoteDataObject$(ePerson); + }, + getEPersonByEmail(email): Observable> { + return createSuccessfulRemoteDataObject$(null); } }; - builderService = getMockFormBuilderService(); + builderService = Object.assign(getMockFormBuilderService(),{ + createFormGroup(formModel, options = null) { + const controls = {}; + formModel.forEach( model => { + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new FormControl(controlState, controlOptions); + }); + return new FormGroup(controls, options); + }, + createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { + return { + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + }; + }, + getValidators(validatorsConfig) { + return this.getValidatorFns(validatorsConfig); + }, + getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { + let validatorFns = []; + if (this.isObject(validatorsConfig)) { + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); + } + return validatorFns; + }, + getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { + let validatorFn; + if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators + validatorFn = Validators[validatorName]; + } else { // Custom Validators + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } + } + if (validatorFn === undefined) { // throw when no validator could be resolved + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + } + if (validatorArgs !== null) { + return validatorFn(validatorArgs); + } + return validatorFn; + }, + isValidatorDescriptor(value) { + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; + }, + isObject(value) { + return typeof value === 'object' && value !== null; + } + }); authService = new AuthServiceStub(); authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(true) + isAuthorized: observableOf(true), + }); groupsDataService = jasmine.createSpyObj('groupsDataService', { findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -156,6 +223,131 @@ describe('EPersonFormComponent', () => { expect(component).toBeDefined(); }); + describe('check form validation', () => { + let firstName; + let lastName; + let email; + let canLogIn; + let requireCertificate; + + let expected; + beforeEach(() => { + firstName = 'testName'; + lastName = 'testLastName'; + email = 'testEmail@test.com'; + canLogIn = false; + requireCertificate = false; + + expected = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: firstName + } + ], + 'eperson.lastname': [ + { + value: lastName + }, + ], + }, + email: email, + canLogIn: canLogIn, + requireCertificate: requireCertificate, + }); + spyOn(component.submitForm, 'emit'); + component.canLogIn.value = canLogIn; + component.requireCertificate.value = requireCertificate; + + fixture.detectChanges(); + component.initialisePage(); + fixture.detectChanges(); + }); + describe('firstName, lastName and email should be required', () => { + it('form should be invalid because the firstName is required', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.firstName.valid).toBeFalse(); + expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); + }); + })); + it('form should be invalid because the lastName is required', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.lastName.valid).toBeFalse(); + expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); + }); + })); + it('form should be invalid because the email is required', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.required).toBeTrue(); + }); + })); + }); + + describe('after inserting information firstName,lastName and email not required', () => { + beforeEach(() => { + component.formGroup.controls.firstName.setValue('test'); + component.formGroup.controls.lastName.setValue('test'); + component.formGroup.controls.email.setValue('test@test.com'); + fixture.detectChanges(); + }); + it('firstName should be valid because the firstName is set', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.firstName.valid).toBeTrue(); + expect(component.formGroup.controls.firstName.errors).toBeNull(); + }); + })); + it('lastName should be valid because the lastName is set', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.lastName.valid).toBeTrue(); + expect(component.formGroup.controls.lastName.errors).toBeNull(); + }); + })); + it('email should be valid because the email is set', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.email.valid).toBeTrue(); + expect(component.formGroup.controls.email.errors).toBeNull(); + }); + })); + }); + + + describe('after inserting email wrong should show pattern validation error', () => { + beforeEach(() => { + component.formGroup.controls.email.setValue('test@test'); + fixture.detectChanges(); + }); + it('email should not be valid because the email pattern', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); + }); + })); + }); + + describe('after already utilized email', () => { + beforeEach(() => { + const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{ + getEPersonByEmail(): Observable> { + return createSuccessfulRemoteDataObject$(EPersonMock); + } + }); + component.formGroup.controls.email.setValue('test@test.com'); + component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson)); + fixture.detectChanges(); + }); + + it('email should not be valid because email is already taken', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); + }); + })); + }); + + + + }); describe('when submitting the form', () => { let firstName; let lastName; diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index d6118a927ad..802fb29adfc 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { DynamicCheckboxModel, @@ -8,7 +8,7 @@ import { } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { switchMap, take } from 'rxjs/operators'; +import { debounceTime, switchMap, take } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; @@ -32,12 +32,14 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { RequestService } from '../../../core/data/request.service'; import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { Registration } from '../../../core/shared/registration.model'; @Component({ selector: 'ds-eperson-form', - templateUrl: './eperson-form.component.html' + templateUrl: './eperson-form.component.html', }) /** * A form used for creating and editing EPeople @@ -162,7 +164,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ isImpersonated = false; - constructor(public epersonService: EPersonDataService, + /** + * Subscription to email field value change + */ + emailValueChangeSubscribe: Subscription; + + constructor(protected changeDetectorRef: ChangeDetectorRef, + public epersonService: EPersonDataService, public groupsDataService: GroupDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, @@ -189,6 +197,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { + observableCombineLatest( this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.lastName`), @@ -221,9 +230,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { name: 'email', validators: { required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', }, required: true, + errorMessages: { + emailTaken: 'error.validation.emailTaken', + pattern: 'error.validation.NotValidEmail' + }, hint: emailHint }); this.canLogIn = new DynamicCheckboxModel( @@ -262,11 +275,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy { canLogIn: eperson != null ? eperson.canLogIn : true, requireCertificate: eperson != null ? eperson.requireCertificate : false }); + + if (eperson === null && !!this.formGroup.controls.email) { + this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService)); + this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); + }); + } })); const activeEPerson$ = this.epersonService.getActiveEPerson(); - this.groups = activeEPerson$.pipe( + this.groups = activeEPerson$.pipe( switchMap((eperson) => { return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { currentPage: 1, @@ -275,14 +295,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }), switchMap(([eperson, findListOptions]) => { if (eperson != null) { - return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions); + return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } return observableOf(undefined); }) ); this.canImpersonate$ = activeEPerson$.pipe( - switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined)) + switchMap((eperson) => { + if (hasValue(eperson)) { + return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); + } else { + return observableOf(false); + } + }) ); this.canDelete$ = activeEPerson$.pipe( switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) @@ -350,10 +376,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy { getFirstCompletedRemoteData() ).subscribe((rd: RemoteData) => { 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); } 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(); } }); @@ -389,10 +415,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy { const response = this.epersonService.updateEPerson(editedEperson); response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { 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); } 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(); } }); @@ -402,28 +428,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } } - /** - * Checks for the given ePerson if there is already an ePerson in the system with that email - * and shows notification if this is the case - * @param ePerson ePerson values to check - * @param notificationSection whether in create or edit - */ - private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) { - // Relevant message for email in use - this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { - currentPage: 1, - elementsPerPage: 0 - }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) - .subscribe((list: PaginatedList) => { - if (list.totalElements > 0) { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { - name: ePerson.name, - email: ePerson.email - })); - } - })); - } - /** * Event triggered when the user changes page * @param event @@ -435,15 +439,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }); } - /** - * Update the list of groups by fetching it from the rest api or cache - */ - private updateGroups(options) { - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); - })); - } - /** * Start impersonating the EPerson */ @@ -457,29 +452,30 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * 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) => { - 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(); - }); - }} - }); + 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) => { + 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(); + }); + } + } + }); }); } @@ -518,9 +514,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.onCancel(); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.paginationService.clearPagination(this.config.id); + if (hasValue(this.emailValueChangeSubscribe)) { + this.emailValueChangeSubscribe.unsubscribe(); + } } - /** * This method will ensure that the page gets reset and that the cache is cleared */ @@ -530,4 +528,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }); this.initialisePage(); } + + /** + * Checks for the given ePerson if there is already an ePerson in the system with that email + * and shows notification if this is the case + * @param ePerson ePerson values to check + * @param notificationSection whether in create or edit + */ + private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) { + // Relevant message for email in use + this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { + currentPage: 1, + elementsPerPage: 0 + }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) + .subscribe((list: PaginatedList) => { + if (list.totalElements > 0) { + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { + name: ePerson.name, + email: ePerson.email + })); + } + })); + } + + /** + * Update the list of groups by fetching it from the rest api or cache + */ + private updateGroups(options) { + this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); + })); + } } diff --git a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts new file mode 100644 index 00000000000..5153abae7c5 --- /dev/null +++ b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts @@ -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 | Observable => { + return ePersonDataService.getEPersonByEmail(control.value) + .pipe( + getFirstSucceededRemoteData(), + map(res => { + return !!res.payload ? { emailTaken: true } : null; + }) + ); + }; + } +} diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 5da621cafe9..e1aca241da6 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -14,9 +14,9 @@ import { Observable, ObservedValueOf, of as observableOf, - Subscription + Subscription, } from 'rxjs'; -import { catchError, map, switchMap, take } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; @@ -34,6 +34,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../../core/shared/operators'; import { AlertType } from '../../../shared/alert/aletr-type'; @@ -65,6 +66,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { * Dynamic models for the inputs of form */ groupName: DynamicInputModel; + groupCommunity: DynamicInputModel; groupDescription: DynamicTextAreaModel; /** @@ -125,16 +127,16 @@ export class GroupFormComponent implements OnInit, OnDestroy { public AlertTypeEnum = AlertType; constructor(public groupDataService: GroupDataService, - private ePersonDataService: EPersonDataService, - private dSpaceObjectDataService: DSpaceObjectDataService, - private formBuilderService: FormBuilderService, - private translateService: TranslateService, - private notificationsService: NotificationsService, - private route: ActivatedRoute, - protected router: Router, - private authorizationService: AuthorizationDataService, - private modalService: NgbModal, - public requestService: RequestService) { + private ePersonDataService: EPersonDataService, + private dSpaceObjectDataService: DSpaceObjectDataService, + private formBuilderService: FormBuilderService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private route: ActivatedRoute, + protected router: Router, + private authorizationService: AuthorizationDataService, + private modalService: NgbModal, + public requestService: RequestService) { } ngOnInit() { @@ -160,8 +162,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { ); observableCombineLatest( this.translateService.get(`${this.messagePrefix}.groupName`), + this.translateService.get(`${this.messagePrefix}.groupCommunity`), this.translateService.get(`${this.messagePrefix}.groupDescription`) - ).subscribe(([groupName, groupDescription]) => { + ).subscribe(([groupName, groupCommunity, groupDescription]) => { this.groupName = new DynamicInputModel({ id: 'groupName', label: groupName, @@ -171,6 +174,13 @@ export class GroupFormComponent implements OnInit, OnDestroy { }, required: true, }); + this.groupCommunity = new DynamicInputModel({ + id: 'groupCommunity', + label: groupCommunity, + name: 'groupCommunity', + required: false, + readOnly: true, + }); this.groupDescription = new DynamicTextAreaModel({ id: 'groupDescription', label: groupDescription, @@ -185,17 +195,36 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.subs.push( observableCombineLatest( this.groupDataService.getActiveGroup(), - this.canEdit$ - ).subscribe(([activeGroup, canEdit]) => { + this.canEdit$, + this.groupDataService.getActiveGroup() + .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) + ).subscribe(([activeGroup, canEdit, linkedObject]) => { + if (activeGroup != null) { this.groupBeingEdited = activeGroup; - this.formGroup.patchValue({ - groupName: activeGroup != null ? activeGroup.name : '', - groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '', - }); - if (!canEdit || activeGroup.permanent) { - this.formGroup.disable(); + + if (linkedObject?.name) { + this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupCommunity: linkedObject?.name ?? '', + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); + } else { + this.formModel = [ + this.groupName, + this.groupDescription, + ]; + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); } + setTimeout(() => { + if (!canEdit || activeGroup.permanent) { + this.formGroup.disable(); + } + }, 200); } }) ); @@ -417,11 +446,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { if (hasValue(group) && hasValue(group._links.object.href)) { return this.getLinkedDSO(group).pipe( map((rd: RemoteData) => { - if (hasValue(rd) && hasValue(rd.payload)) { - return true; - } else { - return false; - } + return hasValue(rd) && hasValue(rd.payload); }), catchError(() => observableOf(false)), ); diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index 51282b49c0a..e5932edf059 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -38,17 +38,22 @@