diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f4a11acc8..7758020724 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 660152349f..69b6132478 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,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) @@ -104,9 +107,9 @@ Default configuration file is located in `src/environments/` folder. To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. - 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. @@ -125,7 +128,7 @@ DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false] ``` The same settings can also be overwritten by setting system environment variables instead, E.g.: -```bash +```bash export DSPACE_HOST=api7.dspace.org ``` @@ -140,7 +143,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 @@ -209,34 +212,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. -Place your tests at the following path: `./e2e` +Before you can run e2e tests, you MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `environment.prod.ts` or `environment.common.ts`. You may override this using env variables, see [Configuring](#configuring). -and run: `ng e2e` +Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results. -### Continuous Integration (CI) Test +#### Writing E2E Tests -To run all the tests (e.g.: to run tests with Continuous Integration software) you can execute:`yarn run ci` Keep in mind that this command prerequisites are the sum of unit test and E2E tests. +All E2E tests must be created under the `./cypress/integration/` folder, and must end in `.spec.ts`. Subfolders are allowed. + +* The easiest way to start creating new tests is by running `ng e2e`. This builds the app and brings up Cypress. +* From here, if you are editing an existing test file, you can either open it in your IDE or run it first to see what it already does. +* To create a new test file, click `+ New Spec File`. Choose a meaningful name ending in `spec.ts` (Please make sure it ends in `.ts` so that it's a Typescript file, and not plain Javascript) +* Start small. Add a basic `describe` and `it` which just [cy.visit](https://docs.cypress.io/api/commands/visit) the page you want to test. For example: + ``` + describe('Community/Collection Browse Page', () => { + it('should exist as a page', () => { + cy.visit('/community-list'); + }); + }); + ``` +* Run your test file from the Cypress window. This starts the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) in a new browser window. +* In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. +* From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page. + * Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector + * Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc. + * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. +* Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly. +* Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests. + +_Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can help prompt/autocomplete your Cypress commands._ + +More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress. + +### Learning how to build tests + +See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips. Documentation -------------- -See [`./docs`](docs) for further documentation. +Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/ + +Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase. ### Building code documentation @@ -259,8 +294,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) @@ -282,95 +315,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 d158577e06..c6607fc80a 100644 --- a/angular.json +++ b/angular.json @@ -147,7 +147,7 @@ "tsConfig": [ "tsconfig.app.json", "tsconfig.spec.json", - "e2e/tsconfig.json" + "cypress/tsconfig.json" ], "exclude": [ "**/node_modules/**" @@ -155,10 +155,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": { @@ -176,16 +177,13 @@ } }, "outputPath": "dist/server", - "main": "src/main.server.ts", + "main": "server.ts", "tsConfig": "tsconfig.server.json" }, "configurations": { "production": { "sourceMap": false, - "optimization": { - "scripts": false, - "styles": true - } + "optimization": true } } }, @@ -215,9 +213,30 @@ "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 + } } } } }, - "defaultProject": "dspace-angular" -} + "defaultProject": "dspace-angular", + "cli": { + "analytics": false + } +} \ No newline at end of file diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..cded267c48 --- /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 0000000000..02e4254378 --- /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 0000000000..62b9a8ad1d --- /dev/null +++ b/cypress/integration/breadcrumbs.spec.ts @@ -0,0 +1,15 @@ +import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('Breadcrumbs', () => { + it('should pass accessibility tests', () => { + // Visit an Item, as those have more breadcrumbs + cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION); + + // Wait for breadcrumbs to be visible + cy.get('ds-breadcrumbs').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-breadcrumbs'); + }); +}); diff --git a/cypress/integration/browse-by-author.spec.ts b/cypress/integration/browse-by-author.spec.ts new file mode 100644 index 0000000000..07c20ad7c9 --- /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 0000000000..4d22420227 --- /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 0000000000..89b791f03c --- /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 0000000000..e4e027586a --- /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 0000000000..a0140d8faf --- /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 0000000000..90b569c824 --- /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'; + +describe('Collection Statistics Page', () => { + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION; + + it('should load if you click on "Statistics" from a Collection page', () => { + cy.visit('/collections/' + TEST_COLLECTION); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + + // 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 0000000000..a7ba72b74a --- /dev/null +++ b/cypress/integration/community-list.spec.ts @@ -0,0 +1,25 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +describe('Community List Page', () => { + + it('should pass accessibility tests', () => { + cy.visit('/community-list'); + + // 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 } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/community-page.spec.ts b/cypress/integration/community-page.spec.ts new file mode 100644 index 0000000000..79e21431ad --- /dev/null +++ b/cypress/integration/community-page.spec.ts @@ -0,0 +1,15 @@ +import { TEST_COMMUNITY } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('Community Page', () => { + + it('should pass accessibility tests', () => { + cy.visit('/communities/' + TEST_COMMUNITY); + + // tag must be loaded + cy.get('ds-community-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-community-page',); + }); +}); diff --git a/cypress/integration/community-statistics.spec.ts b/cypress/integration/community-statistics.spec.ts new file mode 100644 index 0000000000..cbf1783c0b --- /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'; + +describe('Community Statistics Page', () => { + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY; + + it('should load if you click on "Statistics" from a Community page', () => { + cy.visit('/communities/' + TEST_COMMUNITY); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + + // 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 0000000000..656e9d4701 --- /dev/null +++ b/cypress/integration/footer.spec.ts @@ -0,0 +1,13 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Footer', () => { + it('should pass accessibility tests', () => { + cy.visit('/'); + + // Footer must first be visible + cy.get('ds-footer').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-footer'); + }); +}); diff --git a/cypress/integration/header.spec.ts b/cypress/integration/header.spec.ts new file mode 100644 index 0000000000..236208db68 --- /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 0000000000..fe0311f87e --- /dev/null +++ b/cypress/integration/homepage-statistics.spec.ts @@ -0,0 +1,19 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Site Statistics Page', () => { + it('should load if you click on "Statistics" from homepage', () => { + cy.visit('/'); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', '/statistics'); + }); + + it('should pass accessibility tests', () => { + cy.visit('/statistics'); + + // 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 0000000000..ddde260bc7 --- /dev/null +++ b/cypress/integration/homepage.spec.ts @@ -0,0 +1,32 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Homepage', () => { + beforeEach(() => { + // All tests start with visiting homepage + cy.visit('/'); + }); + + it('should display translated title "DSpace Angular :: Home"', () => { + cy.title().should('eq', 'DSpace Angular :: Home'); + }); + + it('should contain a news section', () => { + cy.get('ds-home-news').should('be.visible'); + }); + + it('should have a working search box', () => { + const queryString = 'test'; + cy.get('ds-search-form input[name="query"]').type(queryString); + cy.get('ds-search-form button.search-button').click(); + cy.url().should('include', '/search'); + cy.url().should('include', 'query=' + encodeURI(queryString)); + }); + + it('should pass accessibility tests', () => { + // Wait for homepage tag to appear + cy.get('ds-home-page').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-home-page'); + }); +}); diff --git a/cypress/integration/item-page.spec.ts b/cypress/integration/item-page.spec.ts new file mode 100644 index 0000000000..6a454b678d --- /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'; + +describe('Item Page', () => { + const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION; + const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; + + // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] + it('should redirect to the entity page when navigating to an item page', () => { + cy.visit(ITEMPAGE); + cy.location('pathname').should('eq', ENTITYPAGE); + }); + + it('should pass accessibility tests', () => { + cy.visit(ENTITYPAGE); + + // 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 0000000000..66ebc228db --- /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'; + +describe('Item Statistics Page', () => { + const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION; + + it('should load if you click on "Statistics" from an Item/Entity page', () => { + cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); + }); + + it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('ds-item-statistics-page').should('exist'); + cy.get('ds-item-page').should('not.exist'); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist'); + }); + + it('should contain a "Total visits per month" section', () => { + cy.visit(ITEMSTATISTICSPAGE); + cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist'); + }); + + it('should pass accessibility tests', () => { + cy.visit(ITEMSTATISTICSPAGE); + + // 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 0000000000..48520bcaa3 --- /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 0000000000..19a3d56ed4 --- /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 0000000000..a2bfbe6a5b --- /dev/null +++ b/cypress/integration/search-page.spec.ts @@ -0,0 +1,72 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +describe('Search Page', () => { + // unique ID of the search form (for selecting specific elements below) + const SEARCHFORM_ID = '#search-form'; + + it('should contain query value when navigating to page with query parameter', () => { + const queryString = 'test query'; + cy.visit('/search?query=' + queryString); + cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString); + }); + + it('should redirect to the correct url when query was set and submit button was triggered', () => { + const queryString = 'Another interesting query string'; + cy.visit('/search'); + // Type query in searchbox & click search button + cy.get(SEARCHFORM_ID + ' input[name="query"]').type(queryString); + cy.get(SEARCHFORM_ID + ' button.search-button').click(); + cy.url().should('include', 'query=' + encodeURI(queryString)); + }); + + it('should pass accessibility tests', () => { + cy.visit('/search'); + + // 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 0000000000..c6eb874232 --- /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 0000000000..af1f44a0fc --- /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 0000000000..e8b10b9cfb --- /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 0000000000..96575969e8 --- /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 0000000000..58083003cd --- /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/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index c99e469941..370ccbbdf1 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -81,15 +81,22 @@ services: # Keep Solr data directory between reboots - solr_data:/var/solr/data # Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr + # * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op + # * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core + # to the latest configs. If it's a newly created core, this is a no-op. entrypoint: - /bin/bash - '-c' - | init-var-solr precreate-core authority /opt/solr/server/solr/configsets/dspace/authority + cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority precreate-core oai /opt/solr/server/solr/configsets/dspace/oai + cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai precreate-core search /opt/solr/server/solr/configsets/dspace/search + cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics exec solr -f volumes: assetstore: diff --git a/e2e/protractor-ci.conf.js b/e2e/protractor-ci.conf.js deleted file mode 100644 index 0cfc1f9eaf..0000000000 --- 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 93bf7f3301..0000000000 --- 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 deleted file mode 100644 index 116934c1f8..0000000000 --- a/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ProtractorPage } from './app.po'; - -describe('protractor App', () => { - let page: ProtractorPage; - - beforeEach(() => { - page = new ProtractorPage(); - }); - - it('should display translated title "DSpace Angular :: Home"', () => { - page.navigateTo(); - page.waitUntilNotLoading(); - expect(page.getPageTitleText()).toEqual('DSpace 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 0fd016ec68..0000000000 --- a/e2e/src/app.po.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { browser, element, by, protractor, promise } from 'protractor'; - -export class ProtractorPage { - navigateTo() { - return browser.get('/') - .then(() => browser.waitForAngular()); - } - - getPageTitleText() { - return browser.getTitle(); - } - - getHomePageNewsText() { - return element(by.css('ds-home-news')).getText(); - } - - waitUntilNotLoading(): promise.Promise { - 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 58cf569d2a..0000000000 --- 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'; - -describe('protractor Item statics', () => { - let page: ProtractorPage; - - beforeEach(() => { - page = new ProtractorPage(); - }); - - it('should contain element ds-item-page when navigating when navigating to an item page', () => { - page.navigateToItemPage(); - expect(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 ec227b9636..0000000000 --- a/e2e/src/item-statistics/item-statistics.po.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { browser, element, by } from 'protractor'; - -export class ProtractorPage { - ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067'; - ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067'; - ITEMSTATISTICSPAGE = '/statistics/items/e98b0f27-5c19-49a0-960d-eb6ad5287067'; - - navigateToItemPage() { - return browser.get(this.ITEMPAGE); - } - navigateToItemStatisticsPage() { - return browser.get(this.ITEMSTATISTICSPAGE); - } - - elementTagExists(tag: string) { - return element(by.tagName(tag)).isPresent(); - } -} diff --git a/e2e/src/pagenotfound/pagenotfound.e2e-spec.ts b/e2e/src/pagenotfound/pagenotfound.e2e-spec.ts deleted file mode 100644 index bad2036c3a..0000000000 --- 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 a3c02ab644..0000000000 --- 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 b60f71919d..0000000000 --- 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 c1ac817fd2..0000000000 --- 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 f54fc9b662..0000000000 --- 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 83a66a848b..0000000000 --- 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 fdb29acf69..0000000000 --- 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 652ab409ac..b901c8d2db 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "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", + "config:dev:check:rest": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", "prestart:dev": "yarn run config:dev", "prebuild": "yarn run config:dev", "pretest": "yarn run config:test", @@ -15,26 +17,23 @@ "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", "build:prod": "yarn run build:ssr", - "build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server", - "build:client-and-server-bundles": "ng build --prod && ng run dspace-angular:server:production --bundleDependencies true", + "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "test:watch": "npm-run-all --parallel config:test:watch test", "test": "ng test --sourceMap=true --watch=true", "test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", "lint": "ng lint", "lint-fix": "ng lint --fix=true", "e2e": "ng e2e", - "e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js", - "compile:server": "webpack --config webpack.server.config.js --progress --color", - "serve:ssr": "node dist/server", + "serve:ssr": "node dist/server/main", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", "clean:doc": "rimraf doc", @@ -46,7 +45,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", - "postinstall": "ngcc" + "build:mirador": "webpack --config webpack/webpack.mirador.config.ts", + "merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", + "postinstall": "ngcc", + "cypress:open": "cypress open", + "cypress:run": "cypress run" }, "browser": { "fs": false, @@ -72,6 +75,7 @@ "@angular/platform-server": "~10.2.3", "@angular/router": "~10.2.3", "@angularclass/bootloader": "1.0.1", + "@kolkov/ngx-gallery": "^1.2.3", "@ng-bootstrap/ng-bootstrap": "7.0.0", "@ng-dynamic-forms/core": "^12.0.0", "@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0", @@ -88,9 +92,9 @@ "caniuse-lite": "^1.0.30001165", "cerialize": "0.1.18", "cli-progress": "^3.8.0", + "compression": "^1.7.4", "cookie-parser": "1.4.5", "core-js": "^3.7.0", - "debug-loader": "^0.0.1", "deepmerge": "^4.2.2", "express": "^4.17.1", "express-rate-limit": "^5.1.3", @@ -99,16 +103,21 @@ "filesize": "^6.1.0", "font-awesome": "4.7.0", "https": "1.0.0", + "http-proxy-middleware": "^1.0.5", "js-cookie": "2.2.1", "json5": "^2.1.3", "jsonschema": "1.4.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.10", + "lodash": "^4.17.21", + "mirador": "^3.0.0", + "mirador-dl-plugin": "^0.13.0", + "mirador-share-plugin": "^0.10.0", "moment": "^2.29.1", "morgan": "^1.10.0", "ng-mocks": "10.5.4", "ng2-file-upload": "1.4.0", - "ng2-nouislider": "^1.8.2", + "ng2-nouislider": "^1.8.3", "ngx-infinite-scroll": "^10.0.1", "ngx-moment": "^5.0.0", "ngx-pagination": "5.0.0", @@ -116,25 +125,27 @@ "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", - "sass-resources-loader": "^2.1.1", "sortablejs": "1.13.0", "tslib": "^2.0.0", + "url-parse": "^1.5.3", + "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "^0.10.3", - "@kolkov/ngx-gallery": "^1.2.3" + "zone.js": "^0.10.3" }, "devDependencies": { "@angular-builders/custom-webpack": "10.0.1", - "@angular-devkit/build-angular": "~0.1002.0", + "@angular-devkit/build-angular": "~0.1002.3", "@angular/cli": "~10.2.0", "@angular/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", + "@ngtools/webpack": "10.2.3", "@nguniversal/builders": "~10.1.0", "@types/deep-freeze": "0.1.2", "@types/express": "^4.17.9", @@ -144,16 +155,20 @@ "@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", + "debug-loader": "^0.0.1", "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", "jasmine-marbles": "0.6.0", "jasmine-spec-reporter": "^6.0.0", @@ -175,16 +190,18 @@ "protractor-istanbul-plugin": "2.0.0", "raw-loader": "0.5.1", "rimraf": "^3.0.2", + "rxjs-spy": "^7.5.3", + "sass-resources-loader": "^2.1.1", "script-ext-html-webpack-plugin": "2.1.5", "string-replace-loader": "^2.3.0", "terser-webpack-plugin": "^2.3.1", "ts-loader": "^5.2.0", - "ts-node": "^8.8.1", + "ts-node": "^8.10.2", "tslint": "^6.1.3", "typescript": "~4.0.5", "webpack": "^4.44.2", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.2.0", - "webpack-node-externals": "1.7.2" + "webpack-dev-server": "^4.5.0" } } diff --git a/scripts/merge-i18n-files.ts b/scripts/merge-i18n-files.ts new file mode 100644 index 0000000000..e790828c0d --- /dev/null +++ b/scripts/merge-i18n-files.ts @@ -0,0 +1,99 @@ +import { projectRoot} from '../webpack/helpers'; +const commander = require('commander'); +const fs = require('fs'); +const JSON5 = require('json5'); +const _cliProgress = require('cli-progress'); +const _ = require('lodash'); + +const program = new commander.Command(); +program.version('1.0.0', '-v, --version'); + +const LANGUAGE_FILES_LOCATION = 'src/assets/i18n'; + +parseCliInput(); + +/** + * Purpose: Allows customization of i18n labels from within themes + * e.g. Customize the label "menu.section.browse_global" to display "Browse DSpace" rather than "All of DSpace" + * + * This script uses the i18n files found in a source directory to override settings in files with the same + * name in a destination directory. Only the i18n labels to be overridden need be in the source files. + * + * Execution (using custom theme): + * ``` + * yarn merge-i18n -s src/themes/custom/assets/i18n + * ``` + * + * Input parameters: + * * Output directory: The directory in which the original i18n files are stored + * - Defaults to src/assets/i18n (the default i18n file location) + * - This is where the final output files will be written + * * Source directory: The directory with override files + * - Required + * - Recommended to place override files in the theme directory under assets/i18n (but this is not required) + * - Files must have matching names in both source and destination directories, for example: + * en.json5 in the source directory will be merged with en.json5 in the destination directory + * fr.json5 in the source directory will be merged with fr.json5 in the destination directory + */ +function parseCliInput() { + program + .option('-d, --output-dir ', 'output dir 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 0000000000..b12a9929c2 --- /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 73b88cd0c6..c00bdb5ef5 100644 --- a/server.ts +++ b/server.ts @@ -30,6 +30,7 @@ import { join } from 'path'; import { enableProdMode } from '@angular/core'; import { existsSync } from 'fs'; +import { ngExpressEngine } from '@nguniversal/express-engine'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; @@ -37,16 +38,17 @@ import { hasValue, hasNoValue } from './src/app/shared/empty.util'; import { APP_BASE_HREF } from '@angular/common'; import { UIServerConfig } from './src/config/ui-server-config.interface'; +import { ServerAppModule } from './src/main.server'; + /* * Set path for the browser application's dist folder */ const DIST_FOLDER = join(process.cwd(), 'dist/browser'); +// Set path fir IIIF viewer. +const IIIF_VIEWER = join(process.cwd(), 'dist/iiif'); const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index'; -// * NOTE :: leave this as require() since this file is built Dynamically from webpack -const { ServerAppModule, ngExpressEngine } = require('./dist/server/main'); - const cookieParser = require('cookie-parser'); // The Express app is exported so that it can be used by serverless Functions. @@ -57,7 +59,6 @@ export function app() { */ const server = express(); - /* * If production mode is enabled in the environment file: * - Enable Angular's production mode @@ -135,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); @@ -221,47 +226,59 @@ function run() { }); } -/* - * If SSL is enabled - * - Read credentials from configuration files - * - Call script to start an HTTPS server with these credentials - * When SSL is disabled - * - Start an HTTP server on the configured port and host - */ -if (environment.ui.ssl) { - let serviceKey; - try { - serviceKey = fs.readFileSync('./config/ssl/key.pem'); - } catch (e) { - console.warn('Service key not found at ./config/ssl/key.pem'); - } +function start() { + /* + * If SSL is enabled + * - Read credentials from configuration files + * - Call script to start an HTTPS server with these credentials + * When SSL is disabled + * - Start an HTTP server on the configured port and host + */ + if (environment.ui.ssl) { + let serviceKey; + try { + serviceKey = fs.readFileSync('./config/ssl/key.pem'); + } catch (e) { + console.warn('Service key not found at ./config/ssl/key.pem'); + } - let certificate; - try { - certificate = fs.readFileSync('./config/ssl/cert.pem'); - } catch (e) { - console.warn('Certificate not found at ./config/ssl/key.pem'); - } + let certificate; + try { + certificate = fs.readFileSync('./config/ssl/cert.pem'); + } catch (e) { + console.warn('Certificate not found at ./config/ssl/key.pem'); + } - if (serviceKey && certificate) { - createHttpsServer({ - serviceKey: serviceKey, - certificate: certificate - }); + if (serviceKey && certificate) { + createHttpsServer({ + serviceKey: serviceKey, + certificate: certificate + }); + } else { + console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); + + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] + + pem.createCertificate({ + days: 1, + selfSigned: true + }, (error, keys) => { + createHttpsServer(keys); + }); + } } else { - console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.'); - - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] - - pem.createCertificate({ - days: 1, - selfSigned: true - }, (error, keys) => { - createHttpsServer(keys); - }); + run(); } -} else { - run(); +} + +// Webpack will replace 'require' with '__webpack_require__' +// '__non_webpack_require__' is a proxy to Node 'require' +// The below code is to ensure that the server is run only when not requiring the bundle. +declare const __non_webpack_require__: NodeRequire; +const mainModule = __non_webpack_require__.main; +const moduleFilename = (mainModule && mainModule.filename) || ''; +if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { + start(); } export * from './src/main.server'; 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 c613cfdcad..45326c1abc 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 @@ -52,15 +52,17 @@ - - + + + - - + +
{{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 832f4f6ce5..1f4a106bfa 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 @@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestService } from '../../../core/data/request.service'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; +import { ValidateEmailNotTaken } from './validators/email-taken.validator'; + describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -99,12 +102,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([])), @@ -146,6 +215,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 11c117ef55..723939df77 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,10 +32,12 @@ 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'; @Component({ selector: 'ds-eperson-form', - templateUrl: './eperson-form.component.html' + templateUrl: './eperson-form.component.html', }) /** * A form used for creating and editing EPeople @@ -160,7 +162,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, @@ -186,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { + observableCombineLatest( this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.lastName`), @@ -218,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { name: 'email', validators: { required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', }, required: true, + errorMessages: { + emailTaken: 'error.validation.emailTaken', + pattern: 'error.validation.NotValidEmail' + }, hint: emailHint }); this.canLogIn = new DynamicCheckboxModel( @@ -259,11 +272,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, @@ -272,14 +292,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)) @@ -342,10 +368,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(); } }); @@ -381,10 +407,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(); } }); @@ -394,6 +420,87 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } } + /** + * Event triggered when the user changes page + * @param event + */ + onPageChange(event) { + this.updateGroups({ + currentPage: event, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Start impersonating the EPerson + */ + impersonate() { + this.authService.impersonate(this.epersonInitial.id); + this.isImpersonated = true; + } + + /** + * Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing. + * It'll either show a success or error message depending on whether the delete was successful or not. + */ + delete() { + this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = eperson; + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { + if (confirm) { + if (hasValue(eperson.id)) { + this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { + if (restResponse.hasSucceeded) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name })); + this.submitForm.emit(); + } else { + this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); + } + this.cancelForm.emit(); + }); + } + } + }); + }); + } + + /** + * Stop impersonating the EPerson + */ + stopImpersonating() { + this.authService.stopImpersonatingAndRefresh(); + this.isImpersonated = false; + } + + /** + * Cancel the current edit when component is destroyed & unsub all subscriptions + */ + ngOnDestroy(): void { + this.onCancel(); + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + this.paginationService.clearPagination(this.config.id); + if (hasValue(this.emailValueChangeSubscribe)) { + this.emailValueChangeSubscribe.unsubscribe(); + } + } + + /** + * This method will ensure that the page gets reset and that the cache is cleared + */ + reset() { + this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { + this.requestService.removeByHrefSubstring(eperson.self); + }); + this.initialisePage(); + } + /** * Checks for the given ePerson if there is already an ePerson in the system with that email * and shows notification if this is the case @@ -416,17 +523,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { })); } - /** - * Event triggered when the user changes page - * @param event - */ - onPageChange(event) { - this.updateGroups({ - currentPage: event, - elementsPerPage: this.config.pageSize - }); - } - /** * Update the list of groups by fetching it from the rest api or cache */ @@ -435,71 +531,4 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); })); } - - /** - * Start impersonating the EPerson - */ - impersonate() { - this.authService.impersonate(this.epersonInitial.id); - this.isImpersonated = true; - } - - /** - * Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing. - * It'll either show a success or error message depending on whether the delete was successful or not. - */ - delete() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { - const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = eperson; - modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; - modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; - modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; - modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; - modalRef.componentInstance.brandColor = 'danger'; - modalRef.componentInstance.confirmIcon = 'fas fa-trash'; - modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { - if (confirm) { - if (hasValue(eperson.id)) { - this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { - if (restResponse.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name })); - this.submitForm.emit(); - } else { - this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); - } - this.cancelForm.emit(); - }); - }} - }); - }); - } - - /** - * Stop impersonating the EPerson - */ - stopImpersonating() { - this.authService.stopImpersonatingAndRefresh(); - this.isImpersonated = false; - } - - /** - * Cancel the current edit when component is destroyed & unsub all subscriptions - */ - ngOnDestroy(): void { - this.onCancel(); - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - this.paginationService.clearPagination(this.config.id); - } - - - /** - * This method will ensure that the page gets reset and that the cache is cleared - */ - reset() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { - this.requestService.removeByHrefSubstring(eperson.self); - }); - this.initialisePage(); - } } 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 0000000000..5153abae7c --- /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.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index 5f0f570044..2307f3c6fa 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; @@ -34,6 +34,7 @@ import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mo import { RouterMock } from '../../../shared/mocks/router.mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { Operation } from 'fast-json-patch'; +import { ValidateGroupExists } from './validators/group-exists.validator'; describe('GroupFormComponent', () => { let component: GroupFormComponent; @@ -117,7 +118,69 @@ describe('GroupFormComponent', () => { return null; } }; - builderService = getMockFormBuilderService(); + builderService = Object.assign(getMockFormBuilderService(),{ + createFormGroup(formModel, options = null) { + const controls = {}; + formModel.forEach( model => { + model.parent = parent; + const controlModel = model; + const controlState = { value: controlModel.value, disabled: controlModel.disabled }; + const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); + controls[model.id] = new FormControl(controlState, controlOptions); + }); + return new FormGroup(controls, options); + }, + createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { + return { + validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null, + }; + }, + getValidators(validatorsConfig) { + return this.getValidatorFns(validatorsConfig); + }, + getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) { + let validatorFns = []; + if (this.isObject(validatorsConfig)) { + validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => { + const validatorConfigValue = validatorsConfig[validatorConfigKey]; + if (this.isValidatorDescriptor(validatorConfigValue)) { + const descriptor = validatorConfigValue; + return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken); + } + return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken); + }); + } + return validatorFns; + }, + getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) { + let validatorFn; + if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators + validatorFn = Validators[validatorName]; + } else { // Custom Validators + if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) { + validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName); + } else if (validatorsToken) { + validatorFn = validatorsToken.find(validator => validator.name === validatorName); + } + } + if (validatorFn === undefined) { // throw when no validator could be resolved + throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`); + } + if (validatorArgs !== null) { + return validatorFn(validatorArgs); + } + return validatorFn; + }, + isValidatorDescriptor(value) { + if (this.isObject(value)) { + return value.hasOwnProperty('name') && value.hasOwnProperty('args'); + } + return false; + }, + isObject(value) { + return typeof value === 'object' && value !== null; + } + }); translateService = getMockTranslateService(); router = new RouterMock(); notificationService = new NotificationsServiceStub(); @@ -217,4 +280,72 @@ describe('GroupFormComponent', () => { }); }); + + describe('check form validation', () => { + let groupCommunity; + + beforeEach(() => { + groupName = 'testName'; + groupCommunity = 'testgroupCommunity'; + groupDescription = 'testgroupDescription'; + + expected = Object.assign(new Group(), { + name: groupName, + metadata: { + 'dc.description': [ + { + value: groupDescription + } + ], + }, + }); + spyOn(component.submitForm, 'emit'); + + fixture.detectChanges(); + component.initialisePage(); + fixture.detectChanges(); + }); + describe('groupName, groupCommunity and groupDescription should be required', () => { + it('form should be invalid because the groupName is required', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.groupName.valid).toBeFalse(); + expect(component.formGroup.controls.groupName.errors.required).toBeTrue(); + }); + })); + }); + + describe('after inserting information groupName,groupCommunity and groupDescription not required', () => { + beforeEach(() => { + component.formGroup.controls.groupName.setValue('test'); + fixture.detectChanges(); + }); + it('groupName should be valid because the groupName is set', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.groupName.valid).toBeTrue(); + expect(component.formGroup.controls.groupName.errors).toBeNull(); + }); + })); + }); + + describe('after already utilized groupName', () => { + beforeEach(() => { + const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{ + searchGroups(query: string): Observable>> { + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected])); + } + }); + component.formGroup.controls.groupName.setValue('testName'); + component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup)); + fixture.detectChanges(); + }); + + it('groupName should not be valid because groupName is already taken', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.formGroup.controls.groupName.valid).toBeFalse(); + expect(component.formGroup.controls.groupName.errors.groupExists).toBeTruthy(); + }); + })); + }); + }); + }); 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 7e0329f54f..826b7dbe69 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 @@ -1,4 +1,4 @@ -import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -14,9 +14,9 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf, - Subscription + Subscription, } from 'rxjs'; -import { catchError, map, switchMap, take } from 'rxjs/operators'; +import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; @@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { getRemoteDataPayload, getFirstSucceededRemoteData, - getFirstCompletedRemoteData + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { AlertType } from '../../../shared/alert/aletr-type'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; @@ -44,6 +45,7 @@ import { NotificationsService } from '../../../shared/notifications/notification import { followLink } from '../../../shared/utils/follow-link-config.model'; import { NoContent } from '../../../core/shared/NoContent.model'; import { Operation } from 'fast-json-patch'; +import { ValidateGroupExists } from './validators/group-exists.validator'; @Component({ selector: 'ds-group-form', @@ -65,6 +67,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { * Dynamic models for the inputs of form */ groupName: DynamicInputModel; + groupCommunity: DynamicInputModel; groupDescription: DynamicTextAreaModel; /** @@ -124,17 +127,24 @@ export class GroupFormComponent implements OnInit, OnDestroy { */ public AlertTypeEnum = AlertType; + /** + * Subscription to email field value change + */ + groupNameValueChangeSubscribe: Subscription; + + constructor(public groupDataService: GroupDataService, - private ePersonDataService: EPersonDataService, - private dSpaceObjectDataService: DSpaceObjectDataService, - 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, + protected changeDetectorRef: ChangeDetectorRef) { } ngOnInit() { @@ -160,8 +170,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { ); observableCombineLatest( this.translateService.get(`${this.messagePrefix}.groupName`), + this.translateService.get(`${this.messagePrefix}.groupCommunity`), this.translateService.get(`${this.messagePrefix}.groupDescription`) - ).subscribe(([groupName, groupDescription]) => { + ).subscribe(([groupName, groupCommunity, groupDescription]) => { this.groupName = new DynamicInputModel({ id: 'groupName', label: groupName, @@ -171,6 +182,13 @@ export class GroupFormComponent implements OnInit, OnDestroy { }, required: true, }); + this.groupCommunity = new DynamicInputModel({ + id: 'groupCommunity', + label: groupCommunity, + name: 'groupCommunity', + required: false, + readOnly: true, + }); this.groupDescription = new DynamicTextAreaModel({ id: 'groupDescription', label: groupDescription, @@ -182,20 +200,51 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.groupDescription, ]; this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + + if (!!this.formGroup.controls.groupName) { + this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); + this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); + }); + } + this.subs.push( observableCombineLatest( this.groupDataService.getActiveGroup(), - this.canEdit$ - ).subscribe(([activeGroup, canEdit]) => { + this.canEdit$, + this.groupDataService.getActiveGroup() + .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) + ).subscribe(([activeGroup, canEdit, linkedObject]) => { + if (activeGroup != null) { + + // Disable group name exists validator + this.formGroup.controls.groupName.clearAsyncValidators(); + this.groupBeingEdited = activeGroup; - 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); } }) ); @@ -407,6 +456,11 @@ export class GroupFormComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.groupDataService.cancelEditGroup(); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + + if ( hasValue(this.groupNameValueChangeSubscribe) ) { + this.groupNameValueChangeSubscribe.unsubscribe(); + } + } /** @@ -417,11 +471,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 51282b49c0..e5932edf05 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 @@ - - - + + + + - - + - + - - - + + + + - - + - + - - - + + + + - - + - + - - + + + - - + - + + @@ -56,6 +57,7 @@ + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{ePerson.eperson.id}}{{ePerson.eperson.id}}{{ePerson.eperson.name}} + + {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{ePerson.eperson.id}}{{ePerson.eperson.id}}{{ePerson.eperson.name}} + + {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}}{{group.id}}{{group.name}} + {{(group.object | async)?.payload?.name}}
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}} {{messagePrefix + '.table.edit' | translate}}
{{group.id}}{{group.id}}{{group.name}} + {{(group.object | async)?.payload?.name}}
{{messagePrefix + 'table.id' | translate}} {{messagePrefix + 'table.name' | translate}}{{messagePrefix + 'table.collectionOrCommunity' | translate}} {{messagePrefix + 'table.members' | translate}} {{messagePrefix + 'table.edit' | translate}}
{{groupDto.group.id}} {{groupDto.group.name}}{{(groupDto.group.object | async)?.payload?.name}} {{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}
diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts index 245044f5c9..0b30a551fd 100644 --- a/src/app/access-control/group-registry/groups-registry.component.spec.ts +++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts @@ -152,6 +152,7 @@ describe('GroupRegistryComponent', () => { return createSuccessfulRemoteDataObject$(undefined); } }; + authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); setIsAuthorized(true, true); paginationService = new PaginationServiceStub(); @@ -200,6 +201,13 @@ describe('GroupRegistryComponent', () => { }); }); + it('should display community/collection name if present', () => { + const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)')); + expect(collectionNamesFound.length).toEqual(2); + expect(collectionNamesFound[0].nativeElement.textContent).toEqual(''); + expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName'); + }); + describe('edit buttons', () => { describe('when the user is a general admin', () => { beforeEach(fakeAsync(() => { @@ -213,7 +221,7 @@ describe('GroupRegistryComponent', () => { })); it('should be active', () => { - const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); + const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { expect(editButtonFound.nativeElement.disabled).toBeFalse(); @@ -247,7 +255,7 @@ describe('GroupRegistryComponent', () => { })); it('should be active', () => { - const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); + const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { expect(editButtonFound.nativeElement.disabled).toBeFalse(); @@ -266,7 +274,7 @@ describe('GroupRegistryComponent', () => { })); it('should not be active', () => { - const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); + const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { expect(editButtonFound.nativeElement.disabled).toBeTrue(); diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index ce1b7dedd9..da861518da 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { NoContent } from '../../core/shared/NoContent.model'; import { PaginationService } from '../../core/pagination/pagination.service'; +import { followLink } from '../../shared/utils/follow-link-config.model'; @Component({ selector: 'ds-groups-registry', @@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } return this.groupService.searchGroups(this.currentSearchQuery.trim(), { currentPage: paginationOptions.currentPage, - elementsPerPage: paginationOptions.pageSize - }); + elementsPerPage: paginationOptions.pageSize, + }, true, true, followLink('object')); }), getAllSucceededRemoteData(), getRemoteDataPayload(), diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html index f6bff2dc8f..8706b40ee0 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -1,10 +1,23 @@ - +
diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index a19a1f95e4..50f9f8a79e 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -5,12 +5,15 @@ import { MenuService } from '../../../shared/menu/menu.service'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; import { MenuSection } from '../../../shared/menu/menu.reducer'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { Router } from '@angular/router'; /** * Represents a non-expandable section in the admin sidebar */ @Component({ - selector: 'ds-admin-sidebar-section', + /* tslint:disable:component-selector */ + selector: 'li[ds-admin-sidebar-section]', templateUrl: './admin-sidebar-section.component.html', styleUrls: ['./admin-sidebar-section.component.scss'], @@ -23,12 +26,26 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement */ menuID: MenuID = MenuID.ADMIN; itemModel; - constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) { + hasLink: boolean; + constructor( + @Inject('sectionDataProvider') menuSection: MenuSection, + protected menuService: MenuService, + protected injector: Injector, + protected router: Router, + ) { super(menuSection, menuService, injector); this.itemModel = menuSection.model as LinkMenuItemModel; } ngOnInit(): void { + this.hasLink = isNotEmpty(this.itemModel?.link); super.ngOnInit(); } + + navigate(event: any): void { + event.preventDefault(); + if (this.hasLink) { + this.router.navigate(this.itemModel.link); + } + } } diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.html b/src/app/admin/admin-sidebar/admin-sidebar.component.html index d3d8031994..84402c64e9 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.html @@ -4,24 +4,26 @@ value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'), params: {sidebarWidth: (sidebarWidth | async)} }" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)" - *ngIf="menuVisible | async" (mouseenter)="expandPreview($event)" - (mouseleave)="collapsePreview($event)" + *ngIf="menuVisible | async" + (mouseenter)="handleMouseEnter($event)" + (mouseleave)="handleMouseLeave($event)" role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate"> diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.scss b/src/app/admin/admin-sidebar/admin-sidebar.component.scss index e6eb4a7037..5ed0142c7f 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.scss +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.scss @@ -25,6 +25,11 @@ .navbar-nav { .admin-menu-header { background-color: var(--ds-admin-sidebar-header-bg); + + .sidebar-section { + background-color: inherit; + } + .logo-wrapper { img { height: 20px; @@ -34,6 +39,10 @@ line-height: 1.5; } + .navbar-brand { + margin-right: 0; + } + } } @@ -44,26 +53,64 @@ display: flex; align-content: stretch; background-color: var(--ds-admin-sidebar-bg); + overflow-x: visible; + .nav-item { padding-top: var(--bs-spacer); padding-bottom: var(--bs-spacer); + background-color: inherit; + + &:focus-visible { + // since links fill the whole sidebar, we _inset_ the outline + outline-offset: -4px; + + // replace padding with margins so it doesn't extend over the :focus-visible outline + // → can't remove the padding altogether; the icon needs to fill out + // the collapsed width of the sidebar for the slide animation to look decent. + .shortcut-icon { + padding-left: 0; + padding-right: 0; + margin-left: var(--ds-icon-padding); + margin-right: var(--ds-icon-padding); + } + .logo-wrapper { + margin-right: var(--bs-navbar-padding-x) !important; + } + .navbar-brand { + padding-top: 0; + padding-bottom: 0; + margin-top: var(--bs-navbar-brand-padding-y); + margin-bottom: var(--bs-navbar-brand-padding-y); + } + } } + .shortcut-icon { + background-color: inherit; padding-left: var(--ds-icon-padding); padding-right: var(--ds-icon-padding); - } - .shortcut-icon, .icon-wrapper { - background-color: inherit; z-index: var(--ds-icon-z-index); + align-self: baseline; } + .sidebar-collapsible { + padding-left: 0; + padding-right: var(--bs-spacer); width: var(--ds-sidebar-items-width); position: relative; - a { - padding-right: var(--bs-spacer); - width: 100%; + .toggle { + width: 100%; + } + + ul { + padding-top: var(--bs-spacer); + + li a { + padding-left: var(--bs-spacer); + } } } + &.active > .sidebar-collapsible > .nav-link { color: var(--bs-navbar-dark-active-color); } diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 0a9ef512d7..948d7d86bc 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -113,25 +113,10 @@ describe('AdminSidebarComponent', () => { }); }); - describe('when the collapse icon is clicked', () => { - beforeEach(() => { - spyOn(menuService, 'toggleMenu'); - const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon')); - sidebarToggler.triggerEventHandler('click', { - preventDefault: () => {/**/ - } - }); - }); - - it('should call toggleMenu on the menuService', () => { - expect(menuService.toggleMenu).toHaveBeenCalled(); - }); - }); - describe('when the collapse link is clicked', () => { beforeEach(() => { spyOn(menuService, 'toggleMenu'); - const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a')); + const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > a')); sidebarToggler.triggerEventHandler('click', { preventDefault: () => {/**/ } diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index 53a9ecb2ab..f0d583744c 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,7 +1,7 @@ -import { Component, Injector, OnInit } from '@angular/core'; +import { Component, HostListener, Injector, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { combineLatest, combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { first, map, take } from 'rxjs/operators'; +import { combineLatest, combineLatest as observableCombineLatest, Observable, BehaviorSubject } from 'rxjs'; +import { debounceTime, first, map, take, distinctUntilChanged, withLatestFrom } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; import { ScriptDataService } from '../../core/data/processes/script-data.service'; import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; @@ -60,6 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { */ sidebarExpanded: Observable; + inFocus$: BehaviorSubject; + constructor(protected menuService: MenuService, protected injector: Injector, private variableService: CSSVariableService, @@ -69,6 +71,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { private scriptDataService: ScriptDataService, ) { super(menuService, injector); + this.inFocus$ = new BehaviorSubject(false); } /** @@ -89,10 +92,25 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { this.sidebarOpen = !collapsed; this.sidebarClosed = collapsed; }); - this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed) + this.sidebarExpanded = combineLatest([this.menuCollapsed, this.menuPreviewCollapsed]) .pipe( map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed)) ); + this.inFocus$.pipe( + debounceTime(50), + distinctUntilChanged(), // disregard focusout in situations like --(focusout)-(focusin)-- + withLatestFrom( + combineLatest([this.menuCollapsed, this.menuPreviewCollapsed]) + ), + ).subscribe(([inFocus, [collapsed, previewCollapsed]]) => { + if (collapsed) { + if (inFocus && previewCollapsed) { + this.expandPreview(new Event('focusin → expand')); + } else if (!inFocus && !previewCollapsed) { + this.collapsePreview(new Event('focusout → collapse')); + } + } + }); } /** @@ -590,6 +608,32 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { }); } + @HostListener('focusin') + public handleFocusIn() { + this.inFocus$.next(true); + } + + @HostListener('focusout') + public handleFocusOut() { + this.inFocus$.next(false); + } + + public handleMouseEnter(event: any) { + if (!this.inFocus$.getValue()) { + this.expandPreview(event); + } else { + event.preventDefault(); + } + } + + public handleMouseLeave(event: any) { + if (!this.inFocus$.getValue()) { + this.collapsePreview(event); + } else { + event.preventDefault(); + } + } + /** * Method to change this.collapsed to false when the slide animation ends and is sliding open * @param event The animation event diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html index af014f3e1b..734c3d2ef9 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -1,27 +1,36 @@ - + + diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss index 8670cf19aa..802fade8c1 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss @@ -9,7 +9,7 @@ list-style: disc; color: var(--bs-navbar-dark-color); overflow: hidden; - + margin-bottom: calc(-1 * var(--bs-spacer)); // the bottom-most nav-item is padded, no need for double spacing } .sidebar-collapsible { diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts index 33374f1f46..b1f3a63c06 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.spec.ts @@ -10,6 +10,8 @@ import { Component } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../shared/testing/router.stub'; describe('ExpandableAdminSidebarSectionComponent', () => { let component: ExpandableAdminSidebarSectionComponent; @@ -24,6 +26,7 @@ describe('ExpandableAdminSidebarSectionComponent', () => { { provide: 'sectionDataProvider', useValue: { icon: iconString } }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: Router, useValue: new RouterStub() }, ] }).overrideComponent(ExpandableAdminSidebarSectionComponent, { set: { @@ -46,29 +49,14 @@ describe('ExpandableAdminSidebarSectionComponent', () => { }); it('should set the right icon', () => { - const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas')); + const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas')); expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString); }); - describe('when the icon is clicked', () => { - beforeEach(() => { - spyOn(menuService, 'toggleActiveSection'); - const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon')); - sidebarToggler.triggerEventHandler('click', { - preventDefault: () => {/**/ - } - }); - }); - - it('should call toggleActiveSection on the menuService', () => { - expect(menuService.toggleActiveSection).toHaveBeenCalled(); - }); - }); - describe('when the header text is clicked', () => { beforeEach(() => { spyOn(menuService, 'toggleActiveSection'); - const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a')); + const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item')); sidebarToggler.triggerEventHandler('click', { preventDefault: () => {/**/ } diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 112560de16..aaa6a85c51 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -9,12 +9,14 @@ import { MenuService } from '../../../shared/menu/menu.service'; import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; +import { Router } from '@angular/router'; /** * Represents a expandable section in the sidebar */ @Component({ - selector: 'ds-expandable-admin-sidebar-section', + /* tslint:disable:component-selector */ + selector: 'li[ds-expandable-admin-sidebar-section]', templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'], animations: [rotate, slide, bgColor] @@ -48,9 +50,14 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC */ expanded: Observable; - constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService, - private variableService: CSSVariableService, protected injector: Injector) { - super(menuSection, menuService, injector); + constructor( + @Inject('sectionDataProvider') menuSection, + protected menuService: MenuService, + private variableService: CSSVariableService, + protected injector: Injector, + protected router: Router, + ) { + super(menuSection, menuService, injector, router); } /** diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 81b0755d11..db6b22a023 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -4,7 +4,7 @@ import { Collection } from './core/shared/collection.model'; import { Item } from './core/shared/item.model'; import { getCommunityPageRoute } from './community-page/community-page-routing-paths'; import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths'; -import { getItemPageRoute } from './item-page/item-page-routing-paths'; +import { getItemModuleRoute, getItemPageRoute } from './item-page/item-page-routing-paths'; import { hasValue } from './shared/empty.util'; import { URLCombiner } from './core/url-combiner/url-combiner'; @@ -22,6 +22,15 @@ export function getBitstreamModuleRoute() { export function getBitstreamDownloadRoute(bitstream): string { return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(); } +export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: string, queryParams: any } { + const url = new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(); + return { + routerLink: url, + queryParams: { + bitstream: bitstream.uuid + } + }; +} export const ADMIN_MODULE_PATH = 'admin'; @@ -90,3 +99,8 @@ export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getAccessControlModuleRoute() { return `/${ACCESS_CONTROL_MODULE_PATH}`; } + +export const REQUEST_COPY_MODULE_PATH = 'request-a-copy'; +export function getRequestCopyModulePath() { + return `/${REQUEST_COPY_MODULE_PATH}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 52a07b89f5..157ada622d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -14,7 +14,7 @@ import { PROFILE_MODULE_PATH, REGISTER_PATH, WORKFLOW_ITEM_MODULE_PATH, - LEGACY_BITSTREAM_MODULE_PATH, + LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH, } from './app-routing-paths'; import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; @@ -180,6 +180,11 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu path: INFO_MODULE_PATH, loadChildren: () => import('./info/info.module').then((m) => m.InfoModule), }, + { + path: REQUEST_COPY_MODULE_PATH, + loadChildren: () => import('./request-copy/request-copy.module').then((m) => m.RequestCopyModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: FORBIDDEN_PATH, component: ThemedForbiddenComponent diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 356025da9e..6f06a84144 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -9,7 +9,13 @@ import { Optional, PLATFORM_ID, } from '@angular/core'; -import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { + ActivatedRouteSnapshot, + NavigationCancel, + NavigationEnd, + NavigationStart, ResolveEnd, + Router, +} from '@angular/router'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; @@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit { */ isThemeLoading$: BehaviorSubject = new BehaviorSubject(false); + isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(false); /** * Whether or not the idle modal is is currently open @@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit { this.themeService.getThemeName$().subscribe((themeName: string) => { if (isPlatformBrowser(this.platformId)) { // the theme css will never download server side, so this should only happen on the browser - this.isThemeLoading$.next(true); + this.isThemeCSSLoading$.next(true); } if (hasValue(themeName)) { this.setThemeCss(themeName); @@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - this.router.events.pipe( - // This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component - // More information on this bug-fix: https://blog.angular-university.io/angular-debugging/ - delay(0) - ).subscribe((event) => { + let resolveEndFound = false; + this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { + resolveEndFound = false; this.isRouteLoading$.next(true); + this.isThemeLoading$.next(true); + } else if (event instanceof ResolveEnd) { + resolveEndFound = true; + const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; + this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe( + switchMap((changed) => { + if (changed) { + return this.isThemeCSSLoading$; + } else { + return [false]; + } + }) + ).subscribe((changed) => { + this.isThemeLoading$.next(changed); + }); } else if ( event instanceof NavigationEnd || event instanceof NavigationCancel ) { + if (!resolveEndFound) { + this.isThemeLoading$.next(false); + } this.isRouteLoading$.next(false); } }); @@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit { }); } // the fact that this callback is used, proves we're on the browser. - this.isThemeLoading$.next(false); + this.isThemeCSSLoading$.next(false); }; head.appendChild(link); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 131e6c6b58..e2cb10691b 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; -import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; +import { + DYNAMIC_ERROR_MESSAGES_MATCHER, + DYNAMIC_MATCHER_PROVIDERS, + DynamicErrorMessagesMatcher +} from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; @@ -52,6 +56,7 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { UUIDService } from './core/shared/uuid.service'; import { CookieService } from './core/services/cookie.service'; +import { AbstractControl } from '@angular/forms'; export function getBase() { return environment.ui.nameSpace; @@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer[] { return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; } +/** + * Condition for displaying error messages on email form field + */ +export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = + (control: AbstractControl, model: any, hasFocus: boolean) => { + return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus); + }; + const IMPORTS = [ CommonModule, SharedModule, @@ -146,6 +159,10 @@ const PROVIDERS = [ multi: true, deps: [ CookieService, UUIDService ] }, + { + provide: DYNAMIC_ERROR_MESSAGES_MATCHER, + useValue: ValidateEmailErrorStateMatcher + }, ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/breadcrumbs/breadcrumbs.component.spec.ts b/src/app/breadcrumbs/breadcrumbs.component.spec.ts index dd08a74f23..b63a7cee20 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/app/breadcrumbs/breadcrumbs.component.spec.ts @@ -8,7 +8,7 @@ import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock'; import { RouterTestingModule } from '@angular/router/testing'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { DebugElement } from '@angular/core'; describe('BreadcrumbsComponent', () => { diff --git a/src/app/breadcrumbs/breadcrumbs.component.ts b/src/app/breadcrumbs/breadcrumbs.component.ts index 488fd37a2c..248fb446ed 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.ts +++ b/src/app/breadcrumbs/breadcrumbs.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { Breadcrumb } from './breadcrumb/breadcrumb.model'; import { BreadcrumbsService } from './breadcrumbs.service'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable } from 'rxjs'; /** * Component representing the breadcrumbs of a page diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index e8b368a25f..7835ccc8e5 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -1,18 +1,27 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { DynamicFormControlModel, + DynamicFormOptionConfig, DynamicFormService, - DynamicInputModel, - DynamicTextAreaModel + DynamicSelectModel } from '@ng-dynamic-forms/core'; + import { Collection } from '../../core/shared/collection.model'; import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; -import { TranslateService } from '@ngx-translate/core'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CommunityDataService } from '../../core/data/community-data.service'; import { AuthService } from '../../core/auth/auth.service'; import { RequestService } from '../../core/data/request.service'; import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { EntityTypeService } from '../../core/data/entity-type.service'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; +import { collectionFormEntityTypeSelectionConfig, collectionFormModels, } from './collection-form.models'; +import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type'; /** * Form used for creating and editing collections @@ -22,7 +31,7 @@ import { ObjectCacheService } from '../../core/cache/object-cache.service'; styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'], templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html' }) -export class CollectionFormComponent extends ComColFormComponent { +export class CollectionFormComponent extends ComColFormComponent implements OnInit { /** * @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited */ @@ -34,46 +43,16 @@ export class CollectionFormComponent extends ComColFormComponent { type = Collection.type; /** - * The dynamic form fields used for creating/editing a collection - * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + * The dynamic form field used for entity type selection + * @type {DynamicSelectModel} */ - formModel: DynamicFormControlModel[] = [ - new DynamicInputModel({ - id: 'title', - name: 'dc.title', - required: true, - validators: { - required: null - }, - errorMessages: { - required: 'Please enter a name for this title' - }, - }), - new DynamicTextAreaModel({ - id: 'description', - name: 'dc.description', - }), - new DynamicTextAreaModel({ - id: 'abstract', - name: 'dc.description.abstract', - }), - new DynamicTextAreaModel({ - id: 'rights', - name: 'dc.rights', - }), - new DynamicTextAreaModel({ - id: 'tableofcontents', - name: 'dc.description.tableofcontents', - }), - new DynamicTextAreaModel({ - id: 'license', - name: 'dc.rights.license', - }), - new DynamicTextAreaModel({ - id: 'provenance', - name: 'dc.description.provenance', - }), - ]; + entityTypeSelection: DynamicSelectModel = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig); + + /** + * The dynamic form fields used for creating/editing a collection + * @type {DynamicFormControlModel[]} + */ + formModel: DynamicFormControlModel[]; public constructor(protected formService: DynamicFormService, protected translate: TranslateService, @@ -81,7 +60,43 @@ export class CollectionFormComponent extends ComColFormComponent { protected authService: AuthService, protected dsoService: CommunityDataService, protected requestService: RequestService, - protected objectCache: ObjectCacheService) { + protected objectCache: ObjectCacheService, + protected entityTypeService: EntityTypeService) { super(formService, translate, notificationsService, authService, requestService, objectCache); } + + ngOnInit() { + + let currentRelationshipValue: MetadataValue[]; + if (this.dso && this.dso.metadata) { + currentRelationshipValue = this.dso.metadata['dspace.entity.type']; + } + + const entities$: Observable = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload() + ); + + // retrieve all entity types to populate the dropdowns selection + entities$.subscribe((entityTypes: ItemType[]) => { + + entityTypes + .filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE) + .forEach((type: ItemType, index: number) => { + this.entityTypeSelection.add({ + disabled: false, + label: type.label, + value: type.label + } as DynamicFormOptionConfig); + if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) { + this.entityTypeSelection.select(index); + this.entityTypeSelection.disabled = true; + } + }); + + this.formModel = [...collectionFormModels, this.entityTypeSelection]; + + super.ngOnInit(); + }); + + } } diff --git a/src/app/collection-page/collection-form/collection-form.models.ts b/src/app/collection-page/collection-form/collection-form.models.ts new file mode 100644 index 0000000000..37e9d8a9a0 --- /dev/null +++ b/src/app/collection-page/collection-form/collection-form.models.ts @@ -0,0 +1,46 @@ +import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; +import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model'; + +export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig = { + id: 'entityType', + name: 'dspace.entity.type', + disabled: false +}; + +/** + * The dynamic form fields used for creating/editing a collection + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ +export const collectionFormModels: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'title', + name: 'dc.title', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'Please enter a name for this title' + }, + }), + new DynamicTextAreaModel({ + id: 'description', + name: 'dc.description', + }), + new DynamicTextAreaModel({ + id: 'abstract', + name: 'dc.description.abstract', + }), + new DynamicTextAreaModel({ + id: 'rights', + name: 'dc.rights', + }), + new DynamicTextAreaModel({ + id: 'tableofcontents', + name: 'dc.description.tableofcontents', + }), + new DynamicTextAreaModel({ + id: 'license', + name: 'dc.rights.license', + }) +]; diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 5ae1445cef..0dfd013449 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -27,7 +27,7 @@ import { ItemSelectComponent } from '../../shared/object-select/item-select/item import { ObjectSelectService } from '../../shared/object-select/object-select.service'; import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service.stub'; import { VarDirective } from '../../shared/utils/var.directive'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { RouteService } from '../../core/services/route.service'; import { ErrorComponent } from '../../shared/error/error.component'; import { LoadingComponent } from '../../shared/loading/loading.component'; diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html new file mode 100644 index 0000000000..7dc93e8adf --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html @@ -0,0 +1,54 @@ +
+
+

{{ 'collection.source.controls.head' | translate }}

+
+ {{'collection.source.controls.harvest.status' | translate}} + {{contentSource?.harvestStatus}} +
+
+ {{'collection.source.controls.harvest.start' | translate}} + {{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }} +
+
+ {{'collection.source.controls.harvest.last' | translate}} + {{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }} +
+
+ {{'collection.source.controls.harvest.message' | translate}} + {{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }} +
+ + + + + + + + + +
+
\ No newline at end of file diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss new file mode 100644 index 0000000000..98f634e66b --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss @@ -0,0 +1,3 @@ +.spinner-button { + margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2); +} \ No newline at end of file diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts new file mode 100644 index 0000000000..3eb83ebe8a --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -0,0 +1,232 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ContentSource } from '../../../../core/shared/content-source.model'; +import { Collection } from '../../../../core/shared/collection.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; +import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; +import { HttpClient } from '@angular/common/http'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; +import { Process } from '../../../../process-page/processes/process.model'; +import { of as observableOf } from 'rxjs'; +import { CollectionSourceControlsComponent } from './collection-source-controls.component'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { By } from '@angular/platform-browser'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; + +describe('CollectionSourceControlsComponent', () => { + let comp: CollectionSourceControlsComponent; + let fixture: ComponentFixture; + + const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4'; + let contentSource: ContentSource; + let collection: Collection; + let process: Process; + let bitstream: Bitstream; + + let scriptDataService: ScriptDataService; + let processDataService: ProcessDataService; + let requestService: RequestService; + let notificationsService; + let collectionService: CollectionDataService; + let httpClient: HttpClient; + let bitstreamService: BitstreamDataService; + let scheduler: TestScheduler; + + + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + contentSource = Object.assign(new ContentSource(), { + uuid: uuid, + metadataConfigs: [ + { + id: 'dc', + label: 'Simple Dublin Core', + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + }, + { + id: 'qdc', + label: 'Qualified Dublin Core', + nameSpace: 'http://purl.org/dc/terms/' + }, + { + id: 'dim', + label: 'DSpace Intermediate Metadata', + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' + } + ], + oaiSource: 'oai-harvest-source', + oaiSetId: 'oai-set-id', + _links: {self: {href: 'contentsource-selflink'}} + }); + process = Object.assign(new Process(), { + processId: 'process-id', processStatus: 'COMPLETED', + _links: {output: {href: 'output-href'}} + }); + + bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}}); + + collection = Object.assign(new Collection(), { + uuid: 'fake-collection-id', + _links: {self: {href: 'collection-selflink'}} + }); + notificationsService = new NotificationsServiceStub(); + collectionService = jasmine.createSpyObj('collectionService', { + getContentSource: createSuccessfulRemoteDataObject$(contentSource), + findByHref: createSuccessfulRemoteDataObject$(collection) + }); + scriptDataService = jasmine.createSpyObj('scriptDataService', { + invoke: createSuccessfulRemoteDataObject$(process), + }); + processDataService = jasmine.createSpyObj('processDataService', { + findById: createSuccessfulRemoteDataObject$(process), + }); + bitstreamService = jasmine.createSpyObj('bitstreamService', { + findByHref: createSuccessfulRemoteDataObject$(bitstream), + }); + httpClient = jasmine.createSpyObj('httpClient', { + get: observableOf('Script text'), + }); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule], + declarations: [CollectionSourceControlsComponent, VarDirective], + providers: [ + {provide: ScriptDataService, useValue: scriptDataService}, + {provide: ProcessDataService, useValue: processDataService}, + {provide: RequestService, useValue: requestService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: CollectionDataService, useValue: collectionService}, + {provide: HttpClient, useValue: httpClient}, + {provide: BitstreamDataService, useValue: bitstreamService} + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(CollectionSourceControlsComponent); + comp = fixture.componentInstance; + comp.isEnabled = true; + comp.collection = collection; + comp.shouldShow = true; + fixture.detectChanges(); + }); + describe('init', () => { + it('should', () => { + expect(comp).toBeTruthy(); + }); + }); + describe('testConfiguration', () => { + it('should invoke a script and ping the resulting process until completed and show the resulting info', () => { + comp.testConfiguration(contentSource); + scheduler.flush(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ + {name: '-g', value: null}, + {name: '-a', value: contentSource.oaiSource}, + {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, + ], []); + + expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href); + expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text'); + }); + }); + describe('importNow', () => { + it('should invoke a script that will start the harvest', () => { + comp.importNow(); + scheduler.flush(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ + {name: '-r', value: null}, + {name: '-c', value: collection.uuid}, + ], []); + expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(notificationsService.success).toHaveBeenCalled(); + }); + }); + describe('resetAndReimport', () => { + it('should invoke a script that will start the harvest', () => { + comp.resetAndReimport(); + scheduler.flush(); + + expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [ + {name: '-o', value: null}, + {name: '-c', value: collection.uuid}, + ], []); + expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(notificationsService.success).toHaveBeenCalled(); + }); + }); + describe('the controls', () => { + it('should be shown when shouldShow is true', () => { + comp.shouldShow = true; + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css('button')); + expect(buttons.length).toEqual(3); + }); + it('should be shown when shouldShow is false', () => { + comp.shouldShow = false; + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css('button')); + expect(buttons.length).toEqual(0); + }); + it('should be disabled when isEnabled is false', () => { + comp.shouldShow = true; + comp.isEnabled = false; + + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css('button')); + + expect(buttons[0].nativeElement.disabled).toBeTrue(); + expect(buttons[1].nativeElement.disabled).toBeTrue(); + expect(buttons[2].nativeElement.disabled).toBeTrue(); + }); + it('should be enabled when isEnabled is true', () => { + comp.shouldShow = true; + comp.isEnabled = true; + + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css('button')); + + expect(buttons[0].nativeElement.disabled).toBeFalse(); + expect(buttons[1].nativeElement.disabled).toBeFalse(); + expect(buttons[2].nativeElement.disabled).toBeFalse(); + }); + it('should call the corresponding button when clicked', () => { + spyOn(comp, 'testConfiguration'); + spyOn(comp, 'importNow'); + spyOn(comp, 'resetAndReimport'); + + comp.shouldShow = true; + comp.isEnabled = true; + + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css('button')); + + buttons[0].triggerEventHandler('click', null); + expect(comp.testConfiguration).toHaveBeenCalled(); + + buttons[1].triggerEventHandler('click', null); + expect(comp.importNow).toHaveBeenCalled(); + + buttons[2].triggerEventHandler('click', null); + expect(comp.resetAndReimport).toHaveBeenCalled(); + }); + }); + + +}); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts new file mode 100644 index 0000000000..7113c25e9f --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -0,0 +1,231 @@ +import { Component, Input, OnDestroy } from '@angular/core'; +import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; +import { ContentSource } from '../../../../core/shared/content-source.model'; +import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; +import { + getAllCompletedRemoteData, + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload +} from '../../../../core/shared/operators'; +import { filter, map, switchMap, tap } from 'rxjs/operators'; +import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; +import { ProcessStatus } from '../../../../process-page/processes/process-status.model'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { RequestService } from '../../../../core/data/request.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { Process } from '../../../../process-page/processes/process.model'; +import { TranslateService } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; +import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; + +/** + * Component that contains the controls to run, reset and test the harvest + */ +@Component({ + selector: 'ds-collection-source-controls', + styleUrls: ['./collection-source-controls.component.scss'], + templateUrl: './collection-source-controls.component.html', +}) +export class CollectionSourceControlsComponent implements OnDestroy { + + /** + * Should the controls be enabled. + */ + @Input() isEnabled: boolean; + + /** + * The current collection + */ + @Input() collection: Collection; + + /** + * Should the control section be shown + */ + @Input() shouldShow: boolean; + + contentSource$: Observable; + private subs: Subscription[] = []; + + testConfigRunning$ = new BehaviorSubject(false); + importRunning$ = new BehaviorSubject(false); + reImportRunning$ = new BehaviorSubject(false); + + constructor(private scriptDataService: ScriptDataService, + private processDataService: ProcessDataService, + private requestService: RequestService, + private notificationsService: NotificationsService, + private collectionService: CollectionDataService, + private translateService: TranslateService, + private httpClient: HttpClient, + private bitstreamService: BitstreamDataService + ) { + } + + ngOnInit() { + // ensure the contentSource gets updated after being set to stale + this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe( + getAllSucceededRemoteDataPayload(), + switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)), + getAllSucceededRemoteDataPayload() + ); + } + + /** + * Tests the provided content source's configuration. + * @param contentSource - The content source to be tested + */ + testConfiguration(contentSource) { + this.testConfigRunning$.next(true); + this.subs.push(this.scriptDataService.invoke('harvest', [ + {name: '-g', value: null}, + {name: '-a', value: contentSource.oaiSource}, + {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, + ], []).pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + // show a notification when the script invocation fails + this.notificationsService.error(this.translateService.get('collection.source.controls.test.submit.error')); + this.testConfigRunning$.next(false); + } + }), + // filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful. + filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), + switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), + getAllCompletedRemoteData(), + filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + map((rd) => rd.payload), + hasValueOperator(), + ).subscribe((process: Process) => { + if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && + process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { + // Ping the current process state every 5s + setTimeout(() => { + this.requestService.setStaleByHrefSubstring(process._links.self.href); + }, 5000); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); + this.testConfigRunning$.next(false); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { + this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { + const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') + .replaceAll('The script has started', '') + .replaceAll('The script has completed', ''); + this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); + }); + }); + this.testConfigRunning$.next(false); + } + } + )); + } + + /** + * Start the harvest for the current collection + */ + importNow() { + this.importRunning$.next(true); + this.subs.push(this.scriptDataService.invoke('harvest', [ + {name: '-r', value: null}, + {name: '-c', value: this.collection.uuid}, + ], []) + .pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get('collection.source.controls.import.submit.error')); + this.importRunning$.next(false); + } else { + this.notificationsService.success(this.translateService.get('collection.source.controls.import.submit.success')); + } + }), + filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), + switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), + getAllCompletedRemoteData(), + filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + map((rd) => rd.payload), + hasValueOperator(), + ).subscribe((process) => { + if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && + process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { + // Ping the current process state every 5s + setTimeout(() => { + this.requestService.setStaleByHrefSubstring(process._links.self.href); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + }, 5000); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); + this.importRunning$.next(false); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.importRunning$.next(false); + } + } + )); + } + + /** + * Reset and reimport the current collection + */ + resetAndReimport() { + this.reImportRunning$.next(true); + this.subs.push(this.scriptDataService.invoke('harvest', [ + {name: '-o', value: null}, + {name: '-c', value: this.collection.uuid}, + ], []) + .pipe( + getFirstCompletedRemoteData(), + tap((rd) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get('collection.source.controls.reset.submit.error')); + this.reImportRunning$.next(false); + } else { + this.notificationsService.success(this.translateService.get('collection.source.controls.reset.submit.success')); + } + }), + filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), + switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), + getAllCompletedRemoteData(), + filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), + map((rd) => rd.payload), + hasValueOperator(), + ).subscribe((process) => { + if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && + process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { + // Ping the current process state every 5s + setTimeout(() => { + this.requestService.setStaleByHrefSubstring(process._links.self.href); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + }, 5000); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); + this.reImportRunning$.next(false); + } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.reImportRunning$.next(false); + } + } + )); + } + + ngOnDestroy(): void { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } +} diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index de7f0b4708..b67ee9a1bd 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -1,57 +1,74 @@
-
- - - -
-

{{ 'collection.edit.tabs.source.head' | translate }}

-
- - -
- -

{{ 'collection.edit.tabs.source.form.head' | translate }}

+
+ + + +
+

{{ 'collection.edit.tabs.source.head' | translate }}

+
+ + +
+ +

{{ 'collection.edit.tabs.source.form.head' | translate }}

- -
-
- - - -
+
+
+
+
+
+
+ + + +
+
+
+
+ + + diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts index 869238b956..3fb1a50bf1 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => { label: 'DSpace Intermediate Metadata', nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' } - ] + ], + _links: { self: { href: 'contentsource-selflink' } } }); fieldUpdate = { field: contentSource, @@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => { updateContentSource: observableOf(contentSource), getHarvesterEndpoint: observableOf('harvester-endpoint') }); - requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index c4b42d028d..ae48b9309e 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), take(1) ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); - + this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href); // Update harvester this.collectionRD$.pipe( getFirstSucceededRemoteData(), diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts index b743032c8c..0b09542fa0 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts @@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate import { CollectionSourceComponent } from './collection-source/collection-source.component'; import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; import { CollectionFormModule } from '../collection-form/collection-form.module'; +import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component'; /** * Module that contains all components related to the Edit Collection page administrator functionality @@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module' CollectionRolesComponent, CollectionCurateComponent, CollectionSourceComponent, + + CollectionSourceControlsComponent, CollectionAuthorizationsComponent ] }) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 80ee51cd34..02774b794c 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,9 +1,8 @@ -import { Subscription } from 'rxjs/internal/Subscription'; import { FindListOptions } from '../core/data/request.models'; import { hasValue } from '../shared/empty.util'; import { CommunityListService, FlatNode } from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; -import { BehaviorSubject, Observable, } from 'rxjs'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { finalize } from 'rxjs/operators'; /** diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 09848d9044..5738948ebd 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -42,7 +42,6 @@ import { UnsetUserAsIdleAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { getAllSucceededRemoteDataPayload } from '../shared/operators'; @@ -103,7 +102,7 @@ export class AuthService { */ public authenticate(user: string, password: string): Observable { // Attempt authenticating the user using the supplied credentials. - const body = (`password=${Base64EncodeUrl(password)}&user=${Base64EncodeUrl(user)}`); + const body = (`password=${encodeURIComponent(password)}&user=${encodeURIComponent(user)}`); const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index 66f91dbbd6..af616332c0 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -10,8 +10,7 @@ import { LinkDefinition } from './build-decorators'; import { RemoteData } from '../../data/remote-data'; -import { Observable } from 'rxjs/internal/Observable'; -import { EMPTY } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { ResourceType } from '../../shared/resource-type'; /** diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 00aede27a6..031e5ecf47 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -17,7 +17,7 @@ import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ -} from 'src/app/shared/remote-data.utils'; +} from '../../shared/remote-data.utils'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { Observable } from 'rxjs'; diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index f58f36450f..470c036df2 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -27,12 +27,7 @@ import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { - ContentSourceRequest, - FindListOptions, - UpdateContentSourceRequest, - RestRequest -} from './request.models'; +import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models'; import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; @@ -84,16 +79,48 @@ export class CollectionDataService extends ComColDataService { filter((collections: RemoteData>) => !collections.isResponsePending)); } + /** + * Get all collections the user is authorized to submit to + * + * @param query limit the returned collection to those with metadata values matching the query terms. + * @param entityType The entity type used to limit the returned collection + * @param options The [[FindListOptions]] object + * @param reRequestOnStale Whether or not the request should automatically be re-requested after + * the response becomes stale + * @param linksToFollow The array of [[FollowLinkConfig]] + * @return Observable>> + * collection list + */ + getAuthorizedCollectionByEntityType( + query: string, + entityType: string, + options: FindListOptions = {}, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findSubmitAuthorizedByEntityType'; + options = Object.assign({}, options, { + searchParams: [ + new RequestParam('query', query), + new RequestParam('entityType', entityType) + ] + }); + + return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( + filter((collections: RemoteData>) => !collections.isResponsePending)); + } + /** * Get all collections the user is authorized to submit to, by community * * @param communityId The community id * @param query limit the returned collection to those with metadata values matching the query terms. * @param options The [[FindListOptions]] object + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable>> { const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ @@ -102,7 +129,38 @@ export class CollectionDataService extends ComColDataService { ] }); - return this.searchBy(searchHref, options).pipe( + return this.searchBy(searchHref, options, reRequestOnStale).pipe( + filter((collections: RemoteData>) => !collections.isResponsePending)); + } + /** + * Get all collections the user is authorized to submit to, by community and has the metadata + * + * @param communityId The community id + * @param entityType The entity type used to limit the returned collection + * @param options The [[FindListOptions]] object + * @param reRequestOnStale Whether or not the request should automatically be re-requested after + * the response becomes stale + * @param linksToFollow The array of [[FollowLinkConfig]] + * @return Observable>> + * collection list + */ + getAuthorizedCollectionByCommunityAndEntityType( + communityId: string, + entityType: string, + options: FindListOptions = {}, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType'; + const searchParams = [ + new RequestParam('uuid', communityId), + new RequestParam('entityType', entityType) + ]; + + options = Object.assign({}, options, { + searchParams: searchParams + }); + + return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe( filter((collections: RemoteData>) => !collections.isResponsePending)); } @@ -138,7 +196,7 @@ export class CollectionDataService extends ComColDataService { * Get the collection's content harvester * @param collectionId */ - getContentSource(collectionId: string): Observable> { + getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable> { const href$ = this.getHarvesterEndpoint(collectionId).pipe( isNotEmptyOperator(), take(1) @@ -146,7 +204,7 @@ export class CollectionDataService extends ComColDataService { href$.subscribe((href: string) => { const request = new ContentSourceRequest(this.requestService.generateRequestId(), href); - this.requestService.send(request, true); + this.requestService.send(request, useCachedVersionIfAvailable); }); return this.rdbService.buildSingle(href$); @@ -208,10 +266,20 @@ export class CollectionDataService extends ComColDataService { } /** - * Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item + * Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item * @param item Item we want the owning collection of */ findOwningCollectionFor(item: Item): Observable> { return this.findByHref(item._links.owningCollection.href); } + + /** + * Get a list of mapped collections for the given item. + * @param item Item for which the mapped collections should be retrieved. + * @param findListOptions Pagination and search options. + */ + findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable>> { + return this.findAllByHref(item._links.mappedCollections.href, findListOptions); + } + } diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index ca9ea15bc6..40b9373107 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -10,13 +10,14 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; +import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; -import { switchMap, take, map } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { RemoteData } from './remote-data'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { PaginatedList } from './paginated-list.model'; import { ItemType } from '../shared/item-relationships/item-type.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../shared/operators'; +import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { RelationshipTypeService } from './relationship-type.service'; /** @@ -56,7 +57,7 @@ export class EntityTypeService extends DataService { /** * Check whether a given entity type is the left type of a given relationship type, as an observable boolean * @param relationshipType the relationship type for which to check whether the given entity type is the left type - * @param entityType the entity type for which to check whether it is the left type of the given relationship type + * @param itemType the entity type for which to check whether it is the left type of the given relationship type */ isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable { @@ -67,6 +68,73 @@ export class EntityTypeService extends DataService { ); } + /** + * Returns a list of entity types for which there is at least one collection in which the user is authorized to submit + * + * @param {FindListOptions} options + */ + getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable>> { + const searchHref = 'findAllByAuthorizedCollection'; + + return this.searchBy(searchHref, options).pipe( + filter((type: RemoteData>) => !type.isResponsePending)); + } + + /** + * Used to verify if there are one or more entities available + */ + hasMoreThanOneAuthorized(): Observable { + const findListOptions: FindListOptions = { + elementsPerPage: 2, + currentPage: 1 + }; + return this.getAllAuthorizedRelationshipType(findListOptions).pipe( + map((result: RemoteData>) => { + let output: boolean; + if (result.payload) { + output = ( result.payload.page.length > 1 ); + } else { + output = false; + } + return output; + }) + ); + } + + /** + * It returns a list of entity types for which there is at least one collection + * in which the user is authorized to submit supported by at least one external data source provider + * + * @param {FindListOptions} options + */ + getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable>> { + const searchHref = 'findAllByAuthorizedExternalSource'; + + return this.searchBy(searchHref, options).pipe( + filter((type: RemoteData>) => !type.isResponsePending)); + } + + /** + * Used to verify if there are one or more entities available. To use with external source import. + */ + hasMoreThanOneAuthorizedImport(): Observable { + const findListOptions: FindListOptions = { + elementsPerPage: 2, + currentPage: 1 + }; + return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( + map((result: RemoteData>) => { + let output: boolean; + if (result.payload) { + output = ( result.payload.page.length > 1 ); + } else { + output = false; + } + return output; + }) + ); + } + /** * Get the allowed relationship types for an entity type * @param entityTypeId diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index 768d83c024..2407249615 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -7,7 +7,7 @@ import { PostRequest } from './request.models'; import { Registration } from '../shared/registration.model'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; describe('EpersonRegistrationService', () => { diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index ac045b93b0..15eba0e5db 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -14,6 +14,7 @@ export enum FeatureID { IsCollectionAdmin = 'isCollectionAdmin', IsCommunityAdmin = 'isCommunityAdmin', CanDownload = 'canDownload', + CanRequestACopy = 'canRequestACopy', CanManageVersions = 'canManageVersions', CanManageBitstreamBundles = 'canManageBitstreamBundles', CanManageRelationships = 'canManageRelationships', @@ -21,4 +22,7 @@ export enum FeatureID { CanManagePolicies = 'canManagePolicies', CanMakePrivate = 'canMakePrivate', CanMove = 'canMove', + CanEditVersion = 'canEditVersion', + CanDeleteVersion = 'canDeleteVersion', + CanCreateVersion = 'canCreateVersion', } diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index c1298c054c..b1bc14ec6f 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -14,7 +14,7 @@ import { FindListOptions } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteData } from './remote-data'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list.model'; import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; import { LICENSE } from '../shared/license.resource-type'; diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 30a132aeae..26a6b52cc3 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -31,7 +31,7 @@ describe('ItemDataService', () => { }, removeByHrefSubstring(href: string) { // Do nothing - } + }, }) as RequestService; const rdbService = getMockRemoteDataBuildService(); @@ -184,4 +184,14 @@ describe('ItemDataService', () => { }); }); + describe('when cache is invalidated', () => { + beforeEach(() => { + service = initTestService(); + }); + it('should call setStaleByHrefSubstring', () => { + service.invalidateItemCache('uuid'); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('item/uuid'); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 7a0116fe86..c31b6b3c97 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -59,6 +59,7 @@ export class ItemDataService extends DataService { * Get the endpoint for browsing items * (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued') * @param {FindListOptions} options + * @param linkPath * @returns {Observable} */ public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { @@ -287,4 +288,13 @@ export class ItemDataService extends DataService { switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) ); } + + /** + * Invalidate the cache of the item + * @param itemUUID + */ + invalidateItemCache(itemUUID: string) { + this.requestService.setStaleByHrefSubstring('item/' + itemUUID); + } + } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts new file mode 100644 index 0000000000..0d99ca5cd4 --- /dev/null +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -0,0 +1,95 @@ +import { ItemRequestDataService } from './item-request-data.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ItemRequest } from '../shared/item-request.model'; +import { PostRequest } from './request.models'; +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { RestRequestMethod } from './rest-request-method'; + +describe('ItemRequestDataService', () => { + let service: ItemRequestDataService; + + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: HALEndpointService; + + const restApiEndpoint = 'rest/api/endpoint/'; + const requestId = 'request-id'; + let itemRequest: ItemRequest; + + beforeEach(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestId, + send: '', + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: createSuccessfulRemoteDataObject$(itemRequest), + }); + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(restApiEndpoint), + }); + + service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null); + }); + + describe('requestACopy', () => { + it('should send a POST request containing the provided item request', (done) => { + service.requestACopy(itemRequest).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest)); + done(); + }); + }); + }); + + describe('grant', () => { + let email: RequestCopyEmail; + + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + }); + + it('should send a PUT request containing the correct properties', (done) => { + service.grant(itemRequest.token, email, true).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: JSON.stringify({ + acceptRequest: true, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess: true, + }), + })); + done(); + }); + }); + }); + + describe('deny', () => { + let email: RequestCopyEmail; + + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + }); + + it('should send a PUT request containing the correct properties', (done) => { + service.deny(itemRequest.token, email).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: JSON.stringify({ + acceptRequest: false, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess: false, + }), + })); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts new file mode 100644 index 0000000000..41ad19211a --- /dev/null +++ b/src/app/core/data/item-request-data.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators'; +import { RemoteData } from './remote-data'; +import { PostRequest, PutRequest } from './request.models'; +import { RequestService } from './request.service'; +import { ItemRequest } from '../shared/item-request.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { DataService } from './data.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint + */ +@Injectable( + { + providedIn: 'root', + } +) +export class ItemRequestDataService extends DataService { + + protected linkPath = 'itemrequests'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + ) { + super(); + } + + getItemRequestEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for an {@link ItemRequest} by their token + * @param token + */ + getItemRequestEndpointByToken(token: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}/${token}`)); + } + + /** + * Request a copy of an item + * @param itemRequest + */ + requestACopy(itemRequest: ItemRequest): Observable> { + const requestId = this.requestService.generateRequestId(); + + const href$ = this.getItemRequestEndpoint(); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, itemRequest); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId).pipe( + getFirstCompletedRemoteData() + ); + } + + /** + * Deny the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + */ + deny(token: string, email: RequestCopyEmail): Observable> { + return this.process(token, email, false); + } + + /** + * Grant the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + * @param suggestOpenAccess Whether or not to suggest the item to become open access + */ + grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable> { + return this.process(token, email, true, suggestOpenAccess); + } + + /** + * Process the request of an item + * @param token Token of the {@link ItemRequest} + * @param email Email to send back to the user requesting the item + * @param grant Grant or deny the request (true = grant, false = deny) + * @param suggestOpenAccess Whether or not to suggest the item to become open access + */ + process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable> { + const requestId = this.requestService.generateRequestId(); + + this.getItemRequestEndpointByToken(token).pipe( + distinctUntilChanged(), + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new PutRequest(requestId, endpointURL, JSON.stringify({ + acceptRequest: grant, + responseMessage: email.message, + subject: email.subject, + suggestOpenAccess, + }), options); + }), + sendRequest(this.requestService)).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + +} diff --git a/src/app/core/data/relationship-type.service.ts b/src/app/core/data/relationship-type.service.ts index 4dac044090..964d98115b 100644 --- a/src/app/core/data/relationship-type.service.ts +++ b/src/app/core/data/relationship-type.service.ts @@ -15,13 +15,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RELATIONSHIP_TYPE } from '../shared/item-relationships/relationship-type.resource-type'; -import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; +import { getFirstSucceededRemoteData, getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { ItemDataService } from './item-data.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Check if one side of a RelationshipType is the ItemType with the given label @@ -120,4 +121,33 @@ export class RelationshipTypeService extends DataService { }) ); } + + /** + * Search of the given RelationshipType if has the given itemTypes on its left and right sides. + * Returns an observable of the given RelationshipType if it matches, null if it doesn't + * + * @param type The RelationshipType to check + */ + searchByEntityType(type: string,useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + + return this.searchBy( + 'byEntityType', + { + searchParams: [ + { + fieldName: 'type', + fieldValue: type + }, + { + fieldName: 'size', + fieldValue: 100 + }, + ] + }, useCachedVersionIfAvailable,reRequestOnStale,...linksToFollow).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ) as Observable>; + } + + } diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 727cd105e6..b99541f3c5 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -469,4 +469,58 @@ export class RelationshipService extends DataService { update(object: Relationship): Observable> { return this.put(object); } + + /** + * Patch isn't supported on the relationship endpoint, so use put instead. + * + * @param typeId the relationship type id to apply as a filter to the returned relationships + * @param itemUuid The uuid of the item to be checked on the side defined by relationshipLabel + * @param relationshipLabel the name of the relation as defined from the side of the itemUuid + * @param arrayOfItemIds The uuid of the items to be found on the other side of returned relationships + */ + searchByItemsAndType(typeId: string,itemUuid: string,relationshipLabel: string, arrayOfItemIds: string[] ): Observable>> { + + const searchParams = [ + { + fieldName: 'typeId', + fieldValue: typeId + }, + { + fieldName: 'focusItem', + fieldValue: itemUuid + }, + { + fieldName: 'relationshipLabel', + fieldValue: relationshipLabel + }, + { + fieldName: 'size', + fieldValue: arrayOfItemIds.length + }, + { + fieldName: 'embed', + fieldValue: 'leftItem' + }, + { + fieldName: 'embed', + fieldValue: 'rightItem' + }, + ]; + + arrayOfItemIds.forEach( (itemId) => { + searchParams.push( + { + fieldName: 'relatedItem', + fieldValue: itemId + }, + ); + }); + + return this.searchBy( + 'byItemsAndType', + { + searchParams: searchParams + }) as Observable>>; + + } } diff --git a/src/app/core/data/version-data.service.spec.ts b/src/app/core/data/version-data.service.spec.ts new file mode 100644 index 0000000000..5a8caf31be --- /dev/null +++ b/src/app/core/data/version-data.service.spec.ts @@ -0,0 +1,181 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RequestEntry } from './request.reducer'; +import { HrefOnlyDataService } from './href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; + +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { VersionDataService } from './version-data.service'; +import { Version } from '../shared/version.model'; +import { VersionHistory } from '../shared/version-history.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; + + +describe('VersionDataService test', () => { + let scheduler: TestScheduler; + let service: VersionDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1', + draftVersion: true, + }); + + const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(item), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + version: 1, + }); + const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion); + + const endpointURL = `https://rest.api/rest/api/versioning/versions`; + const findByIdRequestURL = `https://rest.api/rest/api/versioning/versions/${mockVersion.id}`; + const findByIdRequestURL$ = observableOf(findByIdRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new VersionDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparatorEntry + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('(a|)', { + a: mockVersionRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getIDHrefObs').and.returnValue(findByIdRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('getHistoryFromVersion', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(findByIdRequestURL$, true, true, followLink('versionhistory')); + }); + + it('should return a VersionHistory', () => { + const result = service.getHistoryFromVersion(mockVersion, true, true); + const expected = cold('(a|)', { + a: versionHistory + }); + expect(result).toBeObservable(expected); + }); + + it('should return an EMPTY observable when version is not given', () => { + const result = service.getHistoryFromVersion(null); + const expected = cold('|'); + expect(result).toBeObservable(expected); + }); + }); + + describe('getHistoryIdFromVersion', () => { + it('should return the version history id', () => { + spyOn((service as any), 'getHistoryFromVersion').and.returnValue(observableOf(versionHistory)); + + const result = service.getHistoryIdFromVersion(mockVersion); + const expected = cold('(a|)', { + a: versionHistory.id + }); + expect(result).toBeObservable(expected); + }); + }); + }); + +}); diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts index 11a3838eb0..70231122c3 100644 --- a/src/app/core/data/version-data.service.ts +++ b/src/app/core/data/version-data.service.ts @@ -10,10 +10,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { FindListOptions } from './request.models'; -import { Observable } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { dataService } from '../cache/builders/build-decorators'; import { VERSION } from '../shared/version.resource-type'; +import { VersionHistory } from '../shared/version-history.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { map, switchMap } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; /** * Service responsible for handling requests related to the Version object @@ -36,9 +40,29 @@ export class VersionDataService extends DataService { } /** - * Get the endpoint for browsing versions + * Get the version history for the given version + * @param version + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.halService.getEndpoint(this.linkPath); + getHistoryFromVersion(version: Version, useCachedVersionIfAvailable = false, reRequestOnStale = true): Observable { + return isNotEmpty(version) ? this.findById(version.id, useCachedVersionIfAvailable, reRequestOnStale, followLink('versionhistory')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((res: Version) => res.versionhistory), + getFirstSucceededRemoteDataPayload(), + ) : EMPTY; } + + /** + * Get the ID of the version history for the given version + * @param version + */ + getHistoryIdFromVersion(version: Version): Observable { + return this.getHistoryFromVersion(version).pipe( + map((versionHistory: VersionHistory) => versionHistory.id), + ); + } + } diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 3a816936de..207093b4d5 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -6,6 +6,14 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { VersionDataService } from './version-data.service'; +import { fakeAsync, waitForAsync } from '@angular/core/testing'; +import { VersionHistory } from '../shared/version-history.model'; +import { Version } from '../shared/version.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { Item } from '../shared/item.model'; +import { of } from 'rxjs'; +import SpyObj = jasmine.SpyObj; const url = 'fake-url'; @@ -16,9 +24,97 @@ describe('VersionHistoryDataService', () => { let notificationsService: any; let rdbService: RemoteDataBuildService; let objectCache: ObjectCacheService; - let versionService: VersionDataService; + let versionService: SpyObj; let halService: any; + const versionHistoryId = 'version-history-id'; + const versionHistoryDraftId = 'version-history-draft-id'; + const version1Id = 'version-1-id'; + const version2Id = 'version-1-id'; + const item1Uuid = 'item-1-uuid'; + const item2Uuid = 'item-2-uuid'; + const versionHistory = Object.assign(new VersionHistory(), { + id: versionHistoryId, + draftVersion: false, + }); + const versionHistoryDraft = Object.assign(new VersionHistory(), { + id: versionHistoryDraftId, + draftVersion: true, + }); + const version1 = Object.assign(new Version(), { + id: version1Id, + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version1-url', + }, + }, + }); + const version2 = Object.assign(new Version(), { + id: version2Id, + version: 2, + summary: 'second version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, + }); + const versions = [version1, version2]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const item1 = Object.assign(new Item(), { + uuid: item1Uuid, + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(version1), + _links: { + self: { + href: '/items/' + item2Uuid, + } + } + }); + const item2 = Object.assign(new Item(), { + uuid: item2Uuid, + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(version2), + _links: { + self: { + href: '/items/' + item2Uuid, + } + } + }); + const items = [item1, item2]; + version1.item = createSuccessfulRemoteDataObject$(item1); + version2.item = createSuccessfulRemoteDataObject$(item2); + + /** + * Create a VersionHistoryDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); + rdbService = jasmine.createSpyObj('rdbService', { + buildList: jasmine.createSpy('buildList'), + buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'), + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + versionService = jasmine.createSpyObj('objectCache', { + findByHref: jasmine.createSpy('findByHref'), + findAllByHref: jasmine.createSpy('findAllByHref'), + getHistoryFromVersion: jasmine.createSpy('getHistoryFromVersion'), + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + + service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null); + } + beforeEach(() => { createService(); }); @@ -35,24 +131,70 @@ describe('VersionHistoryDataService', () => { }); }); - /** - * Create a VersionHistoryDataService used for testing - * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) - */ - function createService(requestEntry$?) { - requestService = getMockRequestService(requestEntry$); - rdbService = jasmine.createSpyObj('rdbService', { - buildList: jasmine.createSpy('buildList') + describe('when getVersions is called', () => { + beforeEach(waitForAsync(() => { + service.getVersions(versionHistoryId); + })); + it('findAllByHref should have been called', () => { + expect(versionService.findAllByHref).toHaveBeenCalled(); }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') - }); - versionService = jasmine.createSpyObj('objectCache', { - findAllByHref: jasmine.createSpy('findAllByHref') - }); - halService = new HALEndpointServiceStub(url); - notificationsService = new NotificationsServiceStub(); + }); + + describe('when getBrowseEndpoint is called', () => { + it('should return the correct value', () => { + service.getBrowseEndpoint().subscribe((res) => { + expect(res).toBe(url + '/versionhistories'); + }); + }); + }); + + describe('when getVersionsEndpoint is called', () => { + it('should return the correct value', () => { + service.getVersionsEndpoint(versionHistoryId).subscribe((res) => { + expect(res).toBe(url + '/versions'); + }); + }); + }); + + describe('when cache is invalidated', () => { + it('should call setStaleByHrefSubstring', () => { + service.invalidateVersionHistoryCache(versionHistoryId); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('versioning/versionhistories/' + versionHistoryId); + }); + }); + + describe('isLatest$', () => { + beforeEach(waitForAsync(() => { + spyOn(service, 'getLatestVersion$').and.returnValue(of(version2)); + })); + it('should return false for version1', () => { + service.isLatest$(version1).subscribe((res) => { + expect(res).toBe(false); + }); + }); + it('should return true for version2', () => { + service.isLatest$(version2).subscribe((res) => { + expect(res).toBe(true); + }); + }); + }); + + describe('hasDraftVersion$', () => { + beforeEach(waitForAsync(() => { + versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(version1)); + })); + it('should return false if draftVersion is false', fakeAsync(() => { + versionService.getHistoryFromVersion.and.returnValue(of(versionHistory)); + service.hasDraftVersion$('href').subscribe((res) => { + expect(res).toBeFalse(); + }); + })); + it('should return true if draftVersion is true', fakeAsync(() => { + versionService.getHistoryFromVersion.and.returnValue(of(versionHistoryDraft)); + service.hasDraftVersion$('href').subscribe((res) => { + expect(res).toBeTrue(); + }); + })); + }); - service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null); - } }); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index 8f148f168d..4268516e6b 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -8,19 +8,30 @@ import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { FindListOptions } from './request.models'; -import { Observable } from 'rxjs'; +import { FindListOptions, PostRequest, RestRequest } from './request.models'; +import { Observable, of } from 'rxjs'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { Version } from '../shared/version.model'; -import { map, switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { dataService } from '../cache/builders/build-decorators'; import { VERSION_HISTORY } from '../shared/version-history.resource-type'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { VersionDataService } from './version-data.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, + sendRequest +} from '../shared/operators'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { hasValueOperator } from '../../shared/empty.util'; +import { Item } from '../shared/item.model'; /** * Service responsible for handling requests related to the VersionHistory object @@ -79,4 +90,129 @@ export class VersionHistoryDataService extends DataService { return this.versionDataService.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Create a new version for an item + * @param itemHref the item for which create a new version + * @param summary the summary of the new version + */ + createVersion(itemHref: string, summary: string): Observable> { + const requestOptions: HttpOptions = Object.create({}); + let requestHeaders = new HttpHeaders(); + requestHeaders = requestHeaders.append('Content-Type', 'text/uri-list'); + requestOptions.headers = requestHeaders; + + return this.halService.getEndpoint(this.versionsEndpoint).pipe( + take(1), + map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`), + map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, itemHref, requestOptions)), + sendRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), + getFirstCompletedRemoteData() + ) as Observable>; + } + + /** + * Get the latest version in a version history + * @param versionHistory + */ + getLatestVersionFromHistory$(versionHistory: VersionHistory): Observable { + + // Pagination options to fetch a single version on the first page (this is the latest version in the history) + const latestVersionOptions = Object.assign(new PaginationComponentOptions(), { + id: 'item-newest-version-options', + currentPage: 1, + pageSize: 1 + }); + + const latestVersionSearch = new PaginatedSearchOptions({pagination: latestVersionOptions}); + + return this.getVersions(versionHistory.id, latestVersionSearch, false, true, followLink('item')).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + hasValueOperator(), + filter((versions) => versions.page.length > 0), + map((versions) => versions.page[0]) + ); + + } + + /** + * Get the latest version (return null if the specified version is null) + * @param version + */ + getLatestVersion$(version: Version): Observable { + // retrieve again version, including with versionHistory + return version.id ? this.versionDataService.findById(version.id, false, true, followLink('versionhistory')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((res) => res.versionhistory), + getFirstSucceededRemoteDataPayload(), + switchMap((versionHistoryRD) => this.getLatestVersionFromHistory$(versionHistoryRD)), + ) : of(null); + } + + /** + * Check if the given version is the latest (return null if `version` is null) + * @param version + * @returns `true` if the specified version is the latest one, `false` otherwise, or `null` if the specified version is null + */ + isLatest$(version: Version): Observable { + return version ? this.getLatestVersion$(version).pipe( + take(1), + switchMap((latestVersion) => of(version.version === latestVersion.version)) + ) : of(null); + } + + /** + * Check if a worskpace item exists in the version history (return null if there is no version history) + * @param versionHref the href of the version + * @returns `true` if a workspace item exists, `false` otherwise, or `null` if a version history does not exist + */ + hasDraftVersion$(versionHref: string): Observable { + return this.versionDataService.findByHref(versionHref, true, true, followLink('versionhistory')).pipe( + getFirstCompletedRemoteData(), + switchMap((res) => { + if (res.hasSucceeded && !res.hasNoContent) { + return of(res).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => this.versionDataService.getHistoryFromVersion(version)), + map((versionHistory) => versionHistory ? versionHistory.draftVersion : false), + ); + } else { + return of(false); + } + }), + ); + } + + /** + * Get the item of the latest version in a version history + * @param versionHistory + */ + getLatestVersionItemFromHistory$(versionHistory: VersionHistory): Observable { + return this.getLatestVersionFromHistory$(versionHistory).pipe( + switchMap((newLatestVersion) => newLatestVersion.item), + getFirstSucceededRemoteDataPayload(), + ); + } + + /** + * Get the item of the latest version from any version in the version history + * @param version + */ + getVersionHistoryFromVersion$(version: Version): Observable { + return this.versionDataService.getHistoryIdFromVersion(version).pipe( + take(1), + switchMap((res) => this.findById(res)), + getFirstSucceededRemoteDataPayload(), + ); + } + + /** + * Invalidate the cache of the version history + * @param versionHistoryID + */ + invalidateVersionHistoryCache(versionHistoryID: string) { + this.requestService.setStaleByHrefSubstring('versioning/versionhistories/' + versionHistoryID); + } } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index cd7b664379..ac08bda589 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -56,11 +56,11 @@ describe('EPersonDataService', () => { } function init() { - restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; + restEndpointURL = 'https://rest.api/dspace-spring-rest/api/eperson'; epersonsEndpoint = `${restEndpointURL}/epersons`; epeople = [EPersonMock, EPersonMock2]; epeople$ = createSuccessfulRemoteDataObject$(createPaginatedList([epeople])); - rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ }); + rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/dspace-spring-rest/api/eperson/epersons': epeople$ }); halService = new HALEndpointServiceStub(restEndpointURL); TestBed.configureTestingModule({ diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts index cee2945253..c7c59802fd 100644 --- a/src/app/core/locale/locale.service.spec.ts +++ b/src/app/core/locale/locale.service.spec.ts @@ -21,6 +21,7 @@ describe('LocaleService test suite', () => { let spyOnSet; let authService; let routeService; + let document; authService = jasmine.createSpyObj('AuthService', { isAuthenticated: jasmine.createSpy('isAuthenticated'), @@ -43,6 +44,7 @@ describe('LocaleService test suite', () => { { provide: CookieService, useValue: new CookieServiceMock() }, { provide: AuthService, userValue: authService }, { provide: RouteService, useValue: routeServiceStub }, + { provide: Document, useValue: document }, ] }); })); @@ -52,7 +54,8 @@ describe('LocaleService test suite', () => { translateService = TestBed.inject(TranslateService); routeService = TestBed.inject(RouteService); window = new NativeWindowRef(); - service = new LocaleService(window, cookieService, translateService, authService, routeService); + document = { documentElement: { lang: 'en' } }; + service = new LocaleService(window, cookieService, translateService, authService, routeService, document); serviceAsAny = service; spyOnGet = spyOn(cookieService, 'get'); spyOnSet = spyOn(cookieService, 'set'); @@ -114,6 +117,12 @@ describe('LocaleService test suite', () => { expect(translateService.use).toHaveBeenCalledWith('es'); expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es'); }); + + it('should set the current language on the html tag', () => { + spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + service.setCurrentLanguageCode(); + expect((service as any).document.documentElement.lang).toEqual('es'); + }); }); describe('', () => { diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index c73b297e40..1052021479 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -10,6 +10,7 @@ import { combineLatest, Observable, of as observableOf } from 'rxjs'; import { map, mergeMap, take } from 'rxjs/operators'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; +import { DOCUMENT } from '@angular/common'; export const LANG_COOKIE = 'dsLanguage'; @@ -38,7 +39,9 @@ export class LocaleService { protected cookie: CookieService, protected translate: TranslateService, protected authService: AuthService, - protected routeService: RouteService) { + protected routeService: RouteService, + @Inject(DOCUMENT) private document: any + ) { } /** @@ -148,6 +151,7 @@ export class LocaleService { } this.translate.use(lang); this.saveLanguageCodeToCookie(lang); + this.document.documentElement.lang = lang; } /** diff --git a/src/app/core/pagination/pagination.service.ts b/src/app/core/pagination/pagination.service.ts index dae6991834..ef996a26c1 100644 --- a/src/app/core/pagination/pagination.service.ts +++ b/src/app/core/pagination/pagination.service.ts @@ -10,7 +10,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { difference } from '../../shared/object.util'; import { isNumeric } from 'rxjs/internal-compatibility'; - @Injectable({ providedIn: 'root', }) diff --git a/src/app/core/shared/content-source-set-serializer.spec.ts b/src/app/core/shared/content-source-set-serializer.spec.ts new file mode 100644 index 0000000000..2203481250 --- /dev/null +++ b/src/app/core/shared/content-source-set-serializer.spec.ts @@ -0,0 +1,26 @@ +import { ContentSourceSetSerializer } from './content-source-set-serializer'; + +describe('ContentSourceSetSerializer', () => { + let serializer: ContentSourceSetSerializer; + + beforeEach(() => { + serializer = new ContentSourceSetSerializer(); + }); + + describe('Serialize', () => { + it('should return all when the value is empty', () => { + expect(serializer.Serialize('')).toEqual('all'); + }); + it('should return the value when it is not empty', () => { + expect(serializer.Serialize('test-value')).toEqual('test-value'); + }); + }); + describe('Deserialize', () => { + it('should return an empty value when the value is \'all\'', () => { + expect(serializer.Deserialize('all')).toEqual(''); + }); + it('should return the value when it is not \'all\'', () => { + expect(serializer.Deserialize('test-value')).toEqual('test-value'); + }); + }); +}); diff --git a/src/app/core/shared/content-source-set-serializer.ts b/src/app/core/shared/content-source-set-serializer.ts new file mode 100644 index 0000000000..ec0baec5a6 --- /dev/null +++ b/src/app/core/shared/content-source-set-serializer.ts @@ -0,0 +1,31 @@ +import { isEmpty } from '../../shared/empty.util'; + +/** + * Serializer to create convert the 'all' value supported by the server to an empty string and vice versa. + */ +export class ContentSourceSetSerializer { + + /** + * Method to serialize a setId + * @param {string} setId + * @returns {string} the provided set ID, unless when an empty set ID is provided. In that case, 'all' will be returned. + */ + Serialize(setId: string): any { + if (isEmpty(setId)) { + return 'all'; + } + return setId; + } + + /** + * Method to deserialize a setId + * @param {string} setId + * @returns {string} the provided set ID. When 'all' is provided, an empty set ID will be returned. + */ + Deserialize(setId: string): string { + if (setId === 'all') { + return ''; + } + return setId; + } +} diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index 326407822f..40cf43ad0c 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,4 +1,4 @@ -import { autoserializeAs, deserializeAs, deserialize } from 'cerialize'; +import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize'; import { HALLink } from './hal-link.model'; import { MetadataConfig } from './metadata-config.model'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -6,6 +6,7 @@ import { typedObject } from '../cache/builders/build-decorators'; import { CONTENT_SOURCE } from './content-source.resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { ResourceType } from './resource-type'; +import { ContentSourceSetSerializer } from './content-source-set-serializer'; /** * The type of content harvesting used @@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject { /** * OAI Specific set ID */ - @autoserializeAs('oai_set_id') + @deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id') + @serializeAs(new ContentSourceSetSerializer(), 'oai_set_id') oaiSetId: string; /** @@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject { */ metadataConfigs: MetadataConfig[]; + /** + * The current harvest status + */ + @autoserializeAs('harvest_status') + harvestStatus: string; + + /** + * The last's harvest start time + */ + @autoserializeAs('harvest_start_time') + harvestStartTime: string; + + /** + * When the collection was last harvested + */ + @autoserializeAs('last_harvested') + lastHarvested: string; + + /** + * The current harvest message + */ + @autoserializeAs('harvest_message') + message: string; + /** * The {@link HALLink}s for this ContentSource */ diff --git a/src/app/core/shared/external-source.model.ts b/src/app/core/shared/external-source.model.ts index e28c8953e9..0fe41c1156 100644 --- a/src/app/core/shared/external-source.model.ts +++ b/src/app/core/shared/external-source.model.ts @@ -1,10 +1,15 @@ import { autoserialize, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; +import { link, typedObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/object-cache.reducer'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { EXTERNAL_SOURCE } from './external-source.resource-type'; import { HALLink } from './hal-link.model'; import { ResourceType } from './resource-type'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { Observable } from 'rxjs'; +import { ITEM_TYPE } from './item-relationships/item-type.resource-type'; +import { ItemType } from './item-relationships/item-type.model'; /** * Model class for an external source @@ -38,6 +43,13 @@ export class ExternalSource extends CacheableObject { @autoserialize hierarchical: boolean; + /** + * The list of entity types that are compatible with this external source + * Will be undefined unless the entityTypes {@link HALLink} has been resolved. + */ + @link(ITEM_TYPE, true) + entityTypes?: Observable>>; + /** * The {@link HALLink}s for this ExternalSource */ @@ -45,5 +57,6 @@ export class ExternalSource extends CacheableObject { _links: { self: HALLink; entries: HALLink; + entityTypes: HALLink; }; } diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index 98c468e9a3..f673629c8d 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -5,7 +5,7 @@ import { map, take } from 'rxjs/operators'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { hasValue } from '../../shared/empty.util'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable } from 'rxjs'; /** * Provides utility methods to save files on the client-side. diff --git a/src/app/core/shared/item-relationships/item-type.resource-type.ts b/src/app/core/shared/item-relationships/item-type.resource-type.ts index 616dc23b73..e820bc22f4 100644 --- a/src/app/core/shared/item-relationships/item-type.resource-type.ts +++ b/src/app/core/shared/item-relationships/item-type.resource-type.ts @@ -6,5 +6,9 @@ import { ResourceType } from '../resource-type'; * Needs to be in a separate file to prevent circular * dependencies in webpack. */ - export const ITEM_TYPE = new ResourceType('entitytype'); + +/** + * The unset entity type + */ +export const NONE_ENTITY_TYPE = 'none'; diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts new file mode 100644 index 0000000000..08b65abebf --- /dev/null +++ b/src/app/core/shared/item-request.model.ts @@ -0,0 +1,90 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { ResourceType } from './resource-type'; +import { ITEM_REQUEST } from './item-request.resource-type'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { HALLink } from './hal-link.model'; + +/** + * Model class for an ItemRequest + */ +@typedObject +export class ItemRequest implements CacheableObject { + static type = ITEM_REQUEST; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * opaque string which uniquely identifies this request + */ + @autoserialize + token: string; + + /** + * true if the request is for all bitstreams of the item. + */ + @autoserialize + allfiles: boolean; + /** + * email address of the person requesting the files. + */ + @autoserialize + requestEmail: string; + /** + * Human-readable name of the person requesting the files. + */ + @autoserialize + requestName: string; + /** + * arbitrary message provided by the person requesting the files. + */ + @autoserialize + requestMessage: string; + /** + * date that the request was recorded. + */ + @autoserialize + requestDate: string; + /** + * true if the request has been granted. + */ + @autoserialize + acceptRequest: boolean; + /** + * date that the request was granted or denied. + */ + @autoserialize + decisionDate: string; + /** + * date on which the request is considered expired. + */ + @autoserialize + expires: string; + /** + * UUID of the requested Item. + */ + @autoserialize + itemId: string; + /** + * UUID of the requested bitstream. + */ + @autoserialize + bitstreamId: string; + + /** + * The {@link HALLink}s for this ItemRequest + */ + @deserialize + _links: { + self: HALLink; + item: HALLink; + bitstream: HALLink; + }; + +} diff --git a/src/app/core/shared/item-request.resource-type.ts b/src/app/core/shared/item-request.resource-type.ts new file mode 100644 index 0000000000..0535ef1948 --- /dev/null +++ b/src/app/core/shared/item-request.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ItemRequest. + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ITEM_REQUEST = new ResourceType('itemrequest'); diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index c29ac3bd2b..463f61c077 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -1,4 +1,4 @@ -import * as uuidv4 from 'uuid/v4'; +import { v4 as uuidv4 } from 'uuid'; import { autoserialize, Serialize, Deserialize } from 'cerialize'; import { hasValue } from '../../shared/empty.util'; /* tslint:disable:max-classes-per-file */ diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index bc91d0585e..812e65bcba 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -1,5 +1,5 @@ import { isUndefined } from '../../shared/empty.util'; -import * as uuidv4 from 'uuid/v4'; +import { v4 as uuidv4 } from 'uuid'; import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; import { Metadata } from './metadata.utils'; diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index 74af230810..8c37fbc8f5 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -26,7 +26,7 @@ import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../s import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { SearchConfig } from './search-filters/search-config.model'; import { SearchService } from './search.service'; -import { of } from 'rxjs/internal/observable/of'; +import { of } from 'rxjs'; import { PaginationService } from '../../pagination/pagination.service'; /** diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 75723366bc..91916a35ac 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -1,8 +1,8 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; import { map, switchMap, take } from 'rxjs/operators'; -import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { LinkService } from '../../cache/builders/link.service'; import { PaginatedList } from '../../data/paginated-list.model'; import { ResponseParsingService } from '../../data/parsing.service'; @@ -13,7 +13,7 @@ import { DSpaceObject } from '../dspace-object.model'; import { GenericConstructor } from '../generic-constructor'; import { HALEndpointService } from '../hal-endpoint.service'; import { URLCombiner } from '../../url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty, hasValueOperator } from '../../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty } from '../../../shared/empty.util'; import { SearchOptions } from '../../../shared/search/search-options.model'; import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; import { SearchResponseParsingService } from '../../data/search-response-parsing.service'; @@ -21,16 +21,11 @@ import { SearchObjects } from '../../../shared/search/search-objects.model'; import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service'; import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service'; import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; -import { Community } from '../community.model'; import { CommunityDataService } from '../../data/community-data.service'; import { ViewMode } from '../view-mode.model'; import { DSpaceObjectDataService } from '../../data/dspace-object-data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { - getFirstSucceededRemoteData, - getFirstCompletedRemoteData, - getRemoteDataPayload -} from '../operators'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators'; import { RouteService } from '../../services/route.service'; import { SearchResult } from '../../../shared/search/search-result.model'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; @@ -395,48 +390,6 @@ export class SearchService implements OnDestroy { return this.rdb.buildFromHref(href); } - /** - * Request a list of DSpaceObjects that can be used as a scope, based on the current scope - * @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned - * @returns {Observable} Emits a list of DSpaceObjects which represent possible scopes - */ - getScopes(scopeId?: string): Observable { - - if (isEmpty(scopeId)) { - const top: Observable = this.communityService.findTop({ elementsPerPage: 9999 }).pipe( - getFirstSucceededRemoteData(), - map( - (communities: RemoteData>) => communities.payload.page - ) - ); - return top; - } - - const scopeObject: Observable> = this.dspaceObjectService.findById(scopeId).pipe(getFirstSucceededRemoteData()); - const scopeList: Observable = scopeObject.pipe( - switchMap((dsoRD: RemoteData) => { - if ((dsoRD.payload as any).type === Community.type.value) { - const community: Community = dsoRD.payload as Community; - this.linkService.resolveLinks(community, followLink('subcommunities'), followLink('collections')); - return observableCombineLatest([ - community.subcommunities.pipe(getFirstCompletedRemoteData()), - community.collections.pipe(getFirstCompletedRemoteData()) - ]).pipe( - map(([subCommunities, collections]) => { - /*if this is a community, we also need to show the direct children*/ - return [community, ...subCommunities.payload.page, ...collections.payload.page]; - }) - ); - } else { - return observableOf([dsoRD.payload]); - } - } - )); - - return scopeList; - - } - /** * Requests the current view mode based on the current URL * @returns {Observable} The current view mode diff --git a/src/app/core/shared/uuid.service.ts b/src/app/core/shared/uuid.service.ts index 6c02facbac..3b9baf8e8e 100644 --- a/src/app/core/shared/uuid.service.ts +++ b/src/app/core/shared/uuid.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import * as uuidv4 from 'uuid/v4'; +import { v4 as uuidv4 } from 'uuid'; @Injectable() export class UUIDService { diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts index 85578f20fc..1e75b8f321 100644 --- a/src/app/core/shared/version-history.model.ts +++ b/src/app/core/shared/version-history.model.ts @@ -22,6 +22,7 @@ export class VersionHistory extends DSpaceObject { _links: { self: HALLink; versions: HALLink; + draftVersion: HALLink; }; /** @@ -30,6 +31,24 @@ export class VersionHistory extends DSpaceObject { @autoserialize id: string; + /** + * The summary of this Version History + */ + @autoserialize + summary: string; + + /** + * The name of the submitter of this Version History + */ + @autoserialize + submitterName: string; + + /** + * Whether exist a workspace item + */ + @autoserialize + draftVersion: boolean; + /** * The list of versions within this history */ diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts new file mode 100644 index 0000000000..8a5177118d --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -0,0 +1,150 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { WorkflowItemDataService } from './workflowitem-data.service'; +import { WorkflowItem } from './models/workflowitem.model'; + +describe('WorkflowItemDataService test', () => { + let scheduler: TestScheduler; + let service: WorkflowItemDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + const wsi = Object.assign(new WorkflowItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' }); + const wsiRD = createSuccessfulRemoteDataObject(wsi); + + const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; + const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; + const searchRequestURL$ = observableOf(searchRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new WorkflowItemDataService( + comparatorEntry, + halService, + http, + notificationsService, + requestService, + rdbService, + objectCache, + store + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: wsiRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('findByItem', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + }); + + it('should return a RemoteData for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 099cfa8627..384d477110 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -9,7 +9,7 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteByIDRequest } from '../data/request.models'; +import { DeleteByIDRequest, FindListOptions } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @@ -19,6 +19,9 @@ import { hasValue } from '../../shared/empty.util'; import { RemoteData } from '../data/remote-data'; import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { WorkspaceItem } from './models/workspaceitem.model'; +import { RequestParam } from '../cache/models/request-param.model'; /** * A service that provides methods to make REST requests with workflow items endpoint. @@ -27,6 +30,7 @@ import { getFirstCompletedRemoteData } from '../shared/operators'; @dataService(WorkflowItem.type) export class WorkflowItemDataService extends DataService { protected linkPath = 'workflowitems'; + protected searchByItemLinkPath = 'item'; protected responseMsToLive = 10 * 1000; constructor( @@ -86,4 +90,23 @@ export class WorkflowItemDataService extends DataService { return this.rdbService.buildFromRequestUUID(requestId); } + + /** + * Return the WorkflowItem object found through the UUID of an item + * + * @param uuid The uuid of the item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param options The {@link FindListOptions} object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; + const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts new file mode 100644 index 0000000000..da7edccda7 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -0,0 +1,150 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { WorkspaceitemDataService } from './workspaceitem-data.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { WorkspaceItem } from './models/workspaceitem.model'; + +describe('WorkspaceitemDataService test', () => { + let scheduler: TestScheduler; + let service: WorkspaceitemDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + const wsi = Object.assign(new WorkspaceItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' }); + const wsiRD = createSuccessfulRemoteDataObject(wsi); + + const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; + const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; + const searchRequestURL$ = observableOf(searchRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new WorkspaceitemDataService( + comparatorEntry, + halService, + http, + notificationsService, + requestService, + rdbService, + objectCache, + store + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: wsiRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('findByItem', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + }); + + it('should return a RemoteData for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 2fc95bdd00..2813398bb5 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -12,6 +12,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { WorkspaceItem } from './models/workspaceitem.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RequestParam } from '../cache/models/request-param.model'; /** * A service that provides methods to make REST requests with workspaceitems endpoint. @@ -20,6 +25,7 @@ import { WorkspaceItem } from './models/workspaceitem.model'; @dataService(WorkspaceItem.type) export class WorkspaceitemDataService extends DataService { protected linkPath = 'workspaceitems'; + protected searchByItemLinkPath = 'item'; constructor( protected comparator: DSOChangeAnalyzer, @@ -33,4 +39,22 @@ export class WorkspaceitemDataService extends DataService { super(); } + /** + * Return the WorkspaceItem object found through the UUID of an item + * + * @param uuid The uuid of the item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param options The {@link FindListOptions} object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; + const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts index ab9727592e..cdba0e4553 100644 --- a/src/app/core/tasks/claimed-task-data.service.spec.ts +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -8,7 +8,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CoreState } from '../core.reducers'; import { ClaimedTaskDataService } from './claimed-task-data.service'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { FindListOptions } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { getTestScheduler } from 'jasmine-marbles'; diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts index 7279c96e5c..7fe0ec67bf 100644 --- a/src/app/core/tasks/pool-task-data.service.spec.ts +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers'; import { PoolTaskDataService } from './pool-task-data.service'; import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { FindListOptions } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts index f0c86d2abf..fd9aa038b9 100644 --- a/src/app/core/tasks/tasks.service.spec.ts +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -17,7 +17,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { compare, Operation } from 'fast-json-patch'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index a10959c1c8..d527924a28 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -8,11 +8,10 @@ import { HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; -import { throwError } from 'rxjs'; // Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index fdbc486769..0e756b7dc9 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -28,6 +28,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { JournalComponent } from './journal.component'; +import { RouteService } from '../../../../core/services/route.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -86,6 +87,7 @@ describe('JournalComponent', () => { { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: RouteService, useValue: {} } ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts b/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts index 35546a63d6..8fa475a2ce 100644 --- a/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts +++ b/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts @@ -21,6 +21,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RouterStub } from '../../../shared/testing/router.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { AuthServiceStub } from '../../../shared/testing/auth-service.stub'; +import { getTestScheduler } from 'jasmine-marbles'; describe('UploadBistreamComponent', () => { let comp: UploadBitstreamComponent; @@ -76,7 +77,8 @@ describe('UploadBistreamComponent', () => { const restEndpoint = 'fake-rest-endpoint'; const mockItemDataService = jasmine.createSpyObj('mockItemDataService', { getBitstreamsEndpoint: observableOf(restEndpoint), - createBundle: createSuccessfulRemoteDataObject$(createdBundle) + createBundle: createSuccessfulRemoteDataObject$(createdBundle), + getBundles: createSuccessfulRemoteDataObject$([bundle]) }); const bundleService = jasmine.createSpyObj('bundleService', { getBitstreamsEndpoint: observableOf(restEndpoint), @@ -92,6 +94,22 @@ describe('UploadBistreamComponent', () => { removeByHrefSubstring: {} }); + + describe('on init', () => { + beforeEach(waitForAsync(() => { + createUploadBitstreamTestingModule({ + bundle: bundle.id + }); + })); + beforeEach(() => { + loadFixtureAndComp(); + }); + it('should initialize the bundles', () => { + expect(comp.bundlesRD$).toBeDefined(); + getTestScheduler().expectObservable(comp.bundlesRD$).toBe('(a|)', {a: createSuccessfulRemoteDataObject([bundle])}); + }); + }); + describe('when a file is uploaded', () => { beforeEach(waitForAsync(() => { createUploadBitstreamTestingModule({}); diff --git a/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts index 7635758463..f68aca2252 100644 --- a/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts +++ b/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; -import { map, switchMap, take } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; @@ -108,9 +108,7 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy { this.itemId = this.route.snapshot.params.id; this.entityType = this.route.snapshot.params['entity-type']; this.itemRD$ = this.route.data.pipe(map((data) => data.dso)); - this.bundlesRD$ = this.itemRD$.pipe( - switchMap((itemRD: RemoteData) => itemRD.payload.bundles) - ); + this.bundlesRD$ = this.itemService.getBundles(this.itemId); this.selectedBundleId = this.route.snapshot.queryParams.bundle; if (isNotEmpty(this.selectedBundleId)) { this.bundleService.findById(this.selectedBundleId).pipe( diff --git a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts index c15c84a647..b5473fa02d 100644 --- a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts @@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { ItemDataService } from '../../../core/data/item-data.service'; diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 5277fa1a3a..fe46906f47 100644 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -1,10 +1,10 @@
+
+ +
+
+
+ + +
-
-
- - - -
-
+
diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html index 2185108c8f..86ab93662e 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -1,6 +1,6 @@
{{getRelationshipMessageKey() | async | translate}} - diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 6742234058..5b269cbce5 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -22,6 +22,7 @@ import { HostWindowService } from '../../../../shared/host-window.service'; import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; let comp: EditRelationshipListComponent; let fixture: ComponentFixture; @@ -33,6 +34,7 @@ let relationshipService; let selectableListService; let paginationService; let hostWindowService; +const relationshipTypeService = {}; const url = 'http://test-url.com/test-url'; @@ -57,6 +59,7 @@ describe('EditRelationshipListComponent', () => { comp.itemType = entityType; comp.url = url; comp.relationshipType = relationshipType; + comp.hasChanges = observableOf(false); fixture.detectChanges(); }; @@ -181,6 +184,7 @@ describe('EditRelationshipListComponent', () => { { provide: LinkService, useValue: linkService }, { provide: PaginationService, useValue: paginationService }, { provide: HostWindowService, useValue: hostWindowService }, + { provide: RelationshipTypeService, useValue: relationshipTypeService }, ], schemas: [ NO_ERRORS_SCHEMA ] @@ -275,6 +279,24 @@ describe('EditRelationshipListComponent', () => { expect(label).toEqual('isAuthorOfPublication'); }); }); + + + + describe('changes managment for add buttons', () => { + + it('should show enabled add buttons', () => { + const element = de.query(By.css('.btn-success')); + expect(element.nativeElement?.disabled).toBeFalse(); + }); + + it('after hash changes changed', () => { + comp.hasChanges = observableOf(true); + fixture.detectChanges(); + const element = de.query(By.css('.btn-success')); + expect(element.nativeElement?.disabled).toBeTrue(); + }); + }); + }); }); diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 9f417ab799..fe7ff0aacf 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -1,14 +1,9 @@ -import { Component, Input, OnInit, OnDestroy } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - from as observableFrom -} from 'rxjs'; +import { combineLatest as observableCombineLatest, from as observableFrom, BehaviorSubject, Observable, Subscription } from 'rxjs'; import { FieldUpdate, FieldUpdates, @@ -16,39 +11,28 @@ import { } from '../../../../core/data/object-updates/object-updates.reducer'; import { RelationshipService } from '../../../../core/data/relationship.service'; import { Item } from '../../../../core/shared/item.model'; -import { - defaultIfEmpty, - map, - mergeMap, - switchMap, - take, - startWith, - toArray, - tap -} from 'rxjs/operators'; -import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util'; +import { defaultIfEmpty, map, mergeMap, startWith, switchMap, take, tap, toArray } from 'rxjs/operators'; +import { hasNoValue, hasValue, hasValueOperator } from '../../../../shared/empty.util'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { - getRemoteDataPayload, + getAllSucceededRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, - getAllSucceededRemoteData, + getRemoteDataPayload, } from '../../../../core/shared/operators'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; import { RelationshipOptions } from '../../../../shared/form/builder/models/relationship-options.model'; -import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service'; import { SearchResult } from '../../../../shared/search/search-result.model'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { Collection } from '../../../../core/shared/collection.model'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -import { Subscription } from 'rxjs/internal/Subscription'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; @Component({ selector: 'ds-edit-relationship-list', @@ -79,6 +63,16 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { */ @Input() relationshipType: RelationshipType; + /** + * If updated information has changed + */ + @Input() hasChanges!: Observable; + + /** + * The event emmiter to submit the new information + */ + @Output() submit: EventEmitter = new EventEmitter(); + /** * Observable that emits the left and right item type of {@link relationshipType} simultaneously. */ @@ -138,10 +132,12 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { */ modalRef: NgbModalRef; + constructor( protected objectUpdatesService: ObjectUpdatesService, protected linkService: LinkService, protected relationshipService: RelationshipService, + protected relationshipTypeService: RelationshipTypeService, protected modalService: NgbModal, protected paginationService: PaginationService, protected selectableListService: SelectableListService, @@ -207,104 +203,174 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { }); const modalComp: DsDynamicLookupRelationModalComponent = this.modalRef.componentInstance; modalComp.repeatable = true; + modalComp.isEditRelationship = true; modalComp.listId = this.listId; modalComp.item = this.item; + modalComp.relationshipType = this.relationshipType; + modalComp.currentItemIsLeftItem$ = this.currentItemIsLeftItem$; + modalComp.toAdd = []; + modalComp.toRemove = []; + modalComp.isPending = false; + this.item.owningCollection.pipe( getFirstSucceededRemoteDataPayload() ).subscribe((collection: Collection) => { modalComp.collection = collection; }); + modalComp.select = (...selectableObjects: SearchResult[]) => { selectableObjects.forEach((searchResult) => { const relatedItem: Item = searchResult.indexableObject; - this.getFieldUpdatesForRelatedItem(relatedItem) - .subscribe((identifiables) => { - identifiables.forEach((identifiable) => - this.objectUpdatesService.removeSingleFieldUpdate(this.url, identifiable.uuid) - ); - if (identifiables.length === 0) { - this.relationshipService.getNameVariant(this.listId, relatedItem.uuid) - .subscribe((nameVariant) => { - const update = { - uuid: this.relationshipType.id + '-' + relatedItem.uuid, - nameVariant, - type: this.relationshipType, - relatedItem, - } as RelationshipIdentifiable; - this.objectUpdatesService.saveAddFieldUpdate(this.url, update); - }); - } - this.loading$.next(true); - // emit the last page again to trigger a fieldupdates refresh - this.relationshipsRd$.next(this.relationshipsRd$.getValue()); - }); + const foundIndex = modalComp.toRemove.findIndex( el => el.uuid === relatedItem.uuid); + + if (foundIndex !== -1) { + modalComp.toRemove.splice(foundIndex,1); + } else { + + this.getRelationFromId(relatedItem) + .subscribe((relationship: Relationship) => { + if (!relationship ) { + modalComp.toAdd.push(searchResult); + } else { + const foundIndexRemove = modalComp.toRemove.findIndex( el => el.indexableObject.uuid === relatedItem.uuid); + if (foundIndexRemove !== -1) { + modalComp.toRemove.splice(foundIndexRemove,1); + } + } + + this.loading$.next(true); + // emit the last page again to trigger a fieldupdates refresh + this.relationshipsRd$.next(this.relationshipsRd$.getValue()); + }); + } }); }; modalComp.deselect = (...selectableObjects: SearchResult[]) => { selectableObjects.forEach((searchResult) => { const relatedItem: Item = searchResult.indexableObject; - this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.relationshipType.id + '-' + relatedItem.uuid); - this.getFieldUpdatesForRelatedItem(relatedItem) - .subscribe((identifiables) => - identifiables.forEach((identifiable) => - this.objectUpdatesService.saveRemoveFieldUpdate(this.url, identifiable) - ) - ); + + const foundIndex = modalComp.toAdd.findIndex( el => el.indexableObject.uuid === relatedItem.uuid); + + if (foundIndex !== -1) { + modalComp.toAdd.splice(foundIndex,1); + } else { + modalComp.toRemove.push(searchResult); + } + }); + }; + + + + modalComp.submitEv = () => { + + const subscriptions = []; + + modalComp.toAdd.forEach((searchResult: SearchResult) => { + const relatedItem = searchResult.indexableObject; + subscriptions.push(this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe( + map((nameVariant) => { + const update = { + uuid: this.relationshipType.id + '-' + searchResult.indexableObject.uuid, + nameVariant, + type: this.relationshipType, + relatedItem, + } as RelationshipIdentifiable; + this.objectUpdatesService.saveAddFieldUpdate(this.url, update); + return update; + }) + )); }); - this.loading$.next(true); - // emit the last page again to trigger a fieldupdates refresh - this.relationshipsRd$.next(this.relationshipsRd$.getValue()); + modalComp.toRemove.forEach( (searchResult) => { + subscriptions.push(this.relationshipService.getNameVariant(this.listId, searchResult.indexableObjectuuid).pipe( + switchMap((nameVariant) => { + return this.getRelationFromId(searchResult.indexableObject).pipe( + map( (relationship: Relationship) => { + const update = { + uuid: relationship.id, + nameVariant, + type: this.relationshipType, + relationship, + } as RelationshipIdentifiable; + this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update); + return update; + }) + ); + }) + )); + }); + + observableCombineLatest(subscriptions).subscribe( (res) => { + // Wait until the states changes since there are multiple items + setTimeout( () => { + this.submit.emit(); + },1000); + + modalComp.isPending = true; + }); }; + + + modalComp.discardEv = () => { + modalComp.toAdd.forEach( (searchResult) => { + this.selectableListService.deselectSingle(this.listId,searchResult); + }); + + modalComp.toRemove.forEach( (searchResult) => { + this.selectableListService.selectSingle(this.listId,searchResult); + }); + + modalComp.toAdd = []; + modalComp.toRemove = []; + }; + this.relatedEntityType$ .pipe(take(1)) .subscribe((relatedEntityType) => { modalComp.relationshipOptions = Object.assign( new RelationshipOptions(), { relationshipType: relatedEntityType.label, - // filter: this.getRelationshipMessageKey(), searchConfiguration: relatedEntityType.label.toLowerCase(), - nameVariants: true, + nameVariants: 'true', } ); }); this.selectableListService.deselectAll(this.listId); - this.updates$.pipe( - switchMap((updates) => - Object.values(updates).length > 0 ? - observableCombineLatest( - Object.values(updates) - .filter((update) => update.changeType !== FieldChangeType.REMOVE) - .map((update) => { - const field = update.field as RelationshipIdentifiable; - if (field.relationship) { - return this.getRelatedItem(field.relationship); - } else { - return observableOf(field.relatedItem); - } - }) - ) : observableOf([]) - ), - take(1), - map((items) => items.map((item) => { - const searchResult = new ItemSearchResult(); - searchResult.indexableObject = item; - searchResult.hitHighlights = {}; - return searchResult; - })), - ).subscribe((items) => { - this.selectableListService.select(this.listId, items); - }); } + getRelationFromId(relatedItem) { + return this.currentItemIsLeftItem$.pipe( + take(1), + switchMap( isLeft => { + let apiCall; + if (isLeft) { + apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.leftwardType ,[relatedItem.id] ).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ); + } else { + apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.rightwardType ,[relatedItem.id] ).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ); + } + + return apiCall.pipe( + map( (res: PaginatedList) => res.page[0]) + ); + } + )); + } + + + /** * Get the existing field updates regarding a relationship with a given item * @param relatedItem The item for which to get the existing field updates */ private getFieldUpdatesForRelatedItem(relatedItem: Item): Observable { - return this.updates$.pipe( take(1), map((updates) => Object.values(updates) @@ -316,11 +382,34 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { identifiables.map((identifiable) => this.getRelatedItem(identifiable.relationship)) ).pipe( defaultIfEmpty([]), - map((relatedItems) => - identifiables.filter((identifiable, index) => relatedItems[index].uuid === relatedItem.uuid) + map((relatedItems) => { + return identifiables.filter( (identifiable, index) => { + return relatedItems[index].uuid === relatedItem.uuid; + }); + } ), ) - ), + ) + ); + } + + /** + * Check if the given item is related with the item we are editing relationships + * @param relatedItem The item for which to get the existing field updates + */ + private getIsRelatedItem(relatedItem: Item): Observable { + + return this.currentItemIsLeftItem$.pipe( + take(1), + map( isLeft => { + if (isLeft) { + const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.leftwardType ); + return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid ); + } else { + const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.rightwardType ); + return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid ); + } + }) ); } @@ -337,6 +426,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { } ngOnInit(): void { + // store the left and right type of the relationship in a single observable this.relationshipLeftAndRightType$ = observableCombineLatest([ this.relationshipType.leftType, @@ -373,6 +463,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { }) ); + // initialize the pagination options this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig.id = `er${this.relationshipType.id}`; diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index 3a3a8f9675..f13b0a15e5 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -79,7 +79,7 @@ export class EditRelationshipComponent implements OnChanges { * Sets the current relationship based on the fieldUpdate input field */ ngOnChanges(): void { - if (this.relationship) { + if (this.relationship && (!!this.relationship.leftItem || !!this.relationship.rightItem)) { this.leftItem$ = this.relationship.leftItem.pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), diff --git a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html index 7692494fd8..4a53445a6d 100644 --- a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -27,6 +27,8 @@ [item]="item" [itemType]="entityType$ | async" [relationshipType]="relationshipType" + [hasChanges] = hasChanges() + (submit) = submit() > diff --git a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 65fd49f795..59cf9762a4 100644 --- a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -25,6 +25,8 @@ import { RouterStub } from '../../../shared/testing/router.stub'; import { ItemRelationshipsComponent } from './item-relationships.component'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { RelationshipTypeService } from '../../../core/data/relationship-type.service'; +import { relationshipTypes } from '../../../shared/testing/relationship-types.mock'; let comp: any; let fixture: ComponentFixture; @@ -46,6 +48,7 @@ const notificationsService = jasmine.createSpyObj('notificationsService', } ); const router = new RouterStub(); +let relationshipTypeService; let routeStub; let itemService; @@ -178,6 +181,13 @@ describe('ItemRelationshipsComponent', () => { } ); + + relationshipTypeService = jasmine.createSpyObj('searchByEntityType', + { + searchByEntityType: observableOf(relationshipTypes) + } + ); + requestService = jasmine.createSpyObj('requestService', { removeByHrefSubstring: {}, @@ -210,6 +220,7 @@ describe('ItemRelationshipsComponent', () => { { provide: EntityTypeService, useValue: entityTypeService }, { provide: ObjectCacheService, useValue: objectCache }, { provide: RequestService, useValue: requestService }, + { provide: RelationshipTypeService, useValue: relationshipTypeService }, ChangeDetectorRef ], schemas: [ NO_ERRORS_SCHEMA @@ -255,4 +266,22 @@ describe('ItemRelationshipsComponent', () => { expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left'); }); }); + + + + describe('discard', () => { + beforeEach(() => { + comp.item.firstMetadataValue = (info) => { + return 'Publication'; + }; + fixture.detectChanges(); + comp.initializeUpdates(); + fixture.detectChanges(); + }); + + it('it should call relationshipTypeService.searchByEntityType', () => { + expect(relationshipTypeService.searchByEntityType).toHaveBeenCalled(); + }); + }); + }); diff --git a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts index e22ad8ddcb..cbbe935786 100644 --- a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -6,13 +6,8 @@ import { FieldUpdates, RelationshipIdentifiable, } from '../../../core/data/object-updates/object-updates.reducer'; -import { Observable } from 'rxjs/internal/Observable'; import { map, startWith, switchMap, take } from 'rxjs/operators'; -import { - combineLatest as observableCombineLatest, - of as observableOf, - zip as observableZip -} from 'rxjs'; +import { combineLatest as observableCombineLatest, of as observableOf, zip as observableZip, Observable } from 'rxjs'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -32,6 +27,9 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { NoContent } from '../../../core/shared/NoContent.model'; import { hasValue } from '../../../shared/empty.util'; +import { RelationshipTypeService } from '../../../core/data/relationship-type.service'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-item-relationships', @@ -65,7 +63,9 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { public objectCache: ObjectCacheService, public requestService: RequestService, public entityTypeService: EntityTypeService, + protected relationshipTypeService: RelationshipTypeService, public cdr: ChangeDetectorRef, + protected modalService: NgbModal, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, route); } @@ -77,27 +77,16 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { const label = this.item.firstMetadataValue('dspace.entity.type'); if (label !== undefined) { + this.relationshipTypes$ = this.relationshipTypeService.searchByEntityType(label, true, true, ...this.getRelationshipTypeFollowLinks()) + .pipe( + map((relationshipTypes: PaginatedList) => relationshipTypes.page) + ); this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), ); - this.relationshipTypes$ = this.entityType$.pipe( - switchMap((entityType) => - this.entityTypeService.getEntityTypeRelationships( - entityType.id, - true, - true, - followLink('leftType'), - followLink('rightType')) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((relationshipTypes) => relationshipTypes.page), - ) - ), - ); } else { this.entityType$ = observableOf(undefined); } @@ -157,6 +146,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { this.initializeOriginalFields(); this.cdr.detectChanges(); this.displayNotifications(response); + this.modalService.dismissAll(); } }) ); @@ -233,4 +223,13 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent { this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); }); } + + + getRelationshipTypeFollowLinks() { + return [ + followLink('leftType'), + followLink('rightType') + ]; + } + } diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html index acabbd1010..ec0c345d82 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html @@ -1,6 +1,4 @@ -
- -
- +
diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts index c68dcfeba3..f4ed04c2c8 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts @@ -1,5 +1,5 @@ import { ItemVersionHistoryComponent } from './item-version-history.component'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { VarDirective } from '../../../shared/utils/var.directive'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; @@ -18,12 +18,20 @@ describe('ItemVersionHistoryComponent', () => { handle: '123456789/1', }); + const activatedRoute = { + parent: { + parent: { + data: observableOf({dso: createSuccessfulRemoteDataObject(item)}) + } + } + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ItemVersionHistoryComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) } } } + { provide: ActivatedRoute, useValue: activatedRoute } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts index 99ef6b3933..18878109c2 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts @@ -30,6 +30,6 @@ export class ItemVersionHistoryComponent { } ngOnInit(): void { - this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable>; + this.itemRD$ = this.route.parent.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/item-page/field-components/collections/collections.component.html b/src/app/item-page/field-components/collections/collections.component.html index e0f963b5bc..e8f682a182 100644 --- a/src/app/item-page/field-components/collections/collections.component.html +++ b/src/app/item-page/field-components/collections/collections.component.html @@ -1,7 +1,21 @@ - + + +
+ {{'item.page.collections.loading' | translate}} +
+ + + {{'item.page.collections.load-more' | translate}} +
diff --git a/src/app/item-page/field-components/collections/collections.component.spec.ts b/src/app/item-page/field-components/collections/collections.component.spec.ts index 70ce5db760..d5278706da 100644 --- a/src/app/item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/item-page/field-components/collections/collections.component.spec.ts @@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { CollectionsComponent } from './collections.component'; +import { FindListOptions } from '../../../core/data/request.models'; +import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; -let collectionsComponent: CollectionsComponent; -let fixture: ComponentFixture; - -let collectionDataServiceStub; - -const mockCollection1: Collection = Object.assign(new Collection(), { - metadata: { - 'dc.description.abstract': [ - { - language: 'en_US', - value: 'Short description' - } - ] - }, - _links: { - self: { href: 'collection-selflink' } - } +const createMockCollection = (id: string) => Object.assign(new Collection(), { + id: id, + name: `collection-${id}`, }); -const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)}); -const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)}); +const mockItem: Item = new Item(); describe('CollectionsComponent', () => { - collectionDataServiceStub = { - findOwningCollectionFor(item: Item) { - if (item === succeededMockItem) { - return createSuccessfulRemoteDataObject$(mockCollection1); - } else { - return createFailedRemoteDataObject$('error', 500); - } - } - }; + let collectionDataService; + + let mockCollection1: Collection; + let mockCollection2: Collection; + let mockCollection3: Collection; + let mockCollection4: Collection; + + let component: CollectionsComponent; + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + collectionDataService = jasmine.createSpyObj([ + 'findOwningCollectionFor', + 'findMappedCollectionsFor', + ]); + + mockCollection1 = createMockCollection('c1'); + mockCollection2 = createMockCollection('c2'); + mockCollection3 = createMockCollection('c3'); + mockCollection4 = createMockCollection('c4'); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ CollectionsComponent ], providers: [ { provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}, - { provide: CollectionDataService, useValue: collectionDataServiceStub }, + { provide: CollectionDataService, useValue: collectionDataService }, ], schemas: [ NO_ERRORS_SCHEMA ] @@ -59,33 +58,264 @@ describe('CollectionsComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(CollectionsComponent); - collectionsComponent = fixture.componentInstance; - collectionsComponent.label = 'test.test'; - collectionsComponent.separator = '
'; - + component = fixture.componentInstance; + component.item = mockItem; + component.label = 'test.test'; + component.separator = '
'; + component.pageSize = 2; })); - describe('When the requested item request has succeeded', () => { + describe('when the item has only an owning collection', () => { + let mockPage1: PaginatedList; + beforeEach(() => { - collectionsComponent.item = succeededMockItem; + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 0, + totalElements: 0, + }), []); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); fixture.detectChanges(); }); - it('should show the collection', () => { - const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); - expect(collectionField).not.toBeNull(); + it('should display the owning collection', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); }); }); - describe('When the requested item request has failed', () => { + describe('when the item has an owning collection and one mapped collection', () => { + let mockPage1: PaginatedList; + beforeEach(() => { - collectionsComponent.item = failedMockItem; + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 1, + totalElements: 1, + }), [mockCollection2]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); fixture.detectChanges(); }); - it('should not show the collection', () => { - const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); - expect(collectionField).toBeNull(); + it('should display the owning collection and the mapped collection', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(2); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); }); }); + + describe('when the item has an owning collection and multiple mapped collections', () => { + let mockPage1: PaginatedList; + let mockPage2: PaginatedList; + + beforeEach(() => { + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 2, + totalElements: 3, + }), [mockCollection2, mockCollection3]); + + mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 2, + elementsPerPage: 2, + totalPages: 2, + totalElements: 1, + }), [mockCollection4]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValues( + createSuccessfulRemoteDataObject$(mockPage1), + createSuccessfulRemoteDataObject$(mockPage2), + ); + fixture.detectChanges(); + }); + + it('should display the owning collection, two mapped collections and a load more button', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(3); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + + describe('when the load more button is clicked', () => { + beforeEach(() => { + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + loadMoreBtn.nativeElement.click(); + fixture.detectChanges(); + }); + + it('should display the owning collection and three mapped collections', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 2, + })); + + expect(collectionFields.length).toBe(4); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3'); + expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4'); + + expect(component.lastPage$.getValue()).toBe(2); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); + }); + }); + }); + + describe('when the request for the owning collection fails', () => { + let mockPage1: PaginatedList; + + beforeEach(() => { + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 1, + totalElements: 1, + }), [mockCollection2]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$()); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); + fixture.detectChanges(); + }); + + it('should display the mapped collection only', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); + }); + }); + + describe('when the request for the mapped collections fails', () => { + beforeEach(() => { + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + }); + + it('should display the owning collection only', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + + expect(component.lastPage$.getValue()).toBe(0); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + }); + + describe('when both requests fail', () => { + beforeEach(() => { + collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$()); + collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + }); + + it('should display no collections', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(0); + + expect(component.lastPage$.getValue()).toBe(0); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + }); + }); diff --git a/src/app/item-page/field-components/collections/collections.component.ts b/src/app/item-page/field-components/collections/collections.component.ts index 32dc8dfb73..23aff80160 100644 --- a/src/app/item-page/field-components/collections/collections.component.ts +++ b/src/app/item-page/field-components/collections/collections.component.ts @@ -1,14 +1,19 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; -import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; -import { PageInfo } from '../../../core/shared/page-info.model'; import { hasValue } from '../../../shared/empty.util'; +import { FindListOptions } from '../../../core/data/request.models'; +import { + getAllCompletedRemoteData, + getAllSucceededRemoteDataPayload, + getFirstSucceededRemoteDataPayload, + getPaginatedListPayload, +} from '../../../core/shared/operators'; /** * This component renders the parent collections section of the item @@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit { separator = '
'; - collectionsRD$: Observable>>; + /** + * Amount of mapped collections that should be fetched at once. + */ + pageSize = 5; + + /** + * Last page of the mapped collections that has been fetched. + */ + lastPage$: BehaviorSubject = new BehaviorSubject(0); + + /** + * Push an event to this observable to fetch the next page of mapped collections. + * Because this observable is a behavior subject, the first page will be requested + * immediately after subscription. + */ + loadMore$: BehaviorSubject = new BehaviorSubject(undefined); + + /** + * Whether or not a page of mapped collections is currently being loaded. + */ + isLoading$: BehaviorSubject = new BehaviorSubject(true); + + /** + * Whether or not more pages of mapped collections are available. + */ + hasMore$: BehaviorSubject = new BehaviorSubject(true); + + /** + * All collections that have been retrieved so far. This includes the owning collection, + * as well as any number of pages of mapped collections. + */ + collections$: Observable; constructor(private cds: CollectionDataService) { } ngOnInit(): void { - // this.collections = this.item.parents.payload; + const owningCollection$: Observable = this.cds.findOwningCollectionFor(this.item).pipe( + getFirstSucceededRemoteDataPayload(), + startWith(null as Collection), + ); - // TODO: this should use parents, but the collections - // for an Item aren't returned by the REST API yet, - // only the owning collection - this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe( - map((rd: RemoteData) => { - if (hasValue(rd.payload)) { - return new RemoteData( - rd.timeCompleted, - rd.msToLive, - rd.lastUpdated, - rd.state, - rd.errorMessage, - buildPaginatedList({ - elementsPerPage: 10, - totalPages: 1, - currentPage: 1, - totalElements: 1, - _links: { - self: rd.payload._links.self - } - } as PageInfo, [rd.payload]), - rd.statusCode - ); - } else { - return rd as any; - } - }) + const mappedCollections$: Observable = this.loadMore$.pipe( + // update isLoading$ + tap(() => this.isLoading$.next(true)), + + // request next batch of mapped collections + withLatestFrom(this.lastPage$), + switchMap(([_, lastPage]: [void, number]) => { + return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), { + elementsPerPage: this.pageSize, + currentPage: lastPage + 1, + })); + }), + + getAllCompletedRemoteData>(), + + // update isLoading$ + tap(() => this.isLoading$.next(false)), + + getAllSucceededRemoteDataPayload(), + + // update hasMore$ + tap((response: PaginatedList) => this.hasMore$.next(response.currentPage < response.totalPages)), + + // update lastPage$ + tap((response: PaginatedList) => this.lastPage$.next(response.currentPage)), + + getPaginatedListPayload(), + + // add current batch to list of collections + scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []), + + startWith([]), + ) as Observable; + + this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe( + map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => { + return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection)); + }), ); } + + handleLoadMore() { + this.loadMore$.next(); + } + } diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index c5393055df..33acd6650b 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -33,7 +33,7 @@
- + {{"item.page.filesection.download" | translate}}
@@ -74,7 +74,7 @@
- + {{"item.page.filesection.download" | translate}}
diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index 43b37f954a..74ad0aae07 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -22,6 +22,10 @@ export function getItemEditRoute(item: Item) { return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH).toString(); } +export function getItemEditVersionhistoryRoute(item: Item) { + return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH, ITEM_EDIT_VERSIONHISTORY_PATH).toString(); +} + export function getEntityPageRoute(entityType: string, itemId: string) { if (isNotEmpty(entityType)) { return new URLCombiner('/entities', encodeURIComponent(entityType.toLowerCase()), itemId).toString(); @@ -34,5 +38,15 @@ export function getEntityEditRoute(entityType: string, itemId: string) { return new URLCombiner(getEntityPageRoute(entityType, itemId), ITEM_EDIT_PATH).toString(); } +/** + * Get the route to an item's version + * @param versionId the ID of the version for which the route will be retrieved + */ +export function getItemVersionRoute(versionId: string) { + return new URLCombiner(getItemModuleRoute(), ITEM_VERSION_PATH, versionId).toString(); +} + export const ITEM_EDIT_PATH = 'edit'; +export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; +export const ITEM_VERSION_PATH = 'version'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index f2d0a23935..7d7912bb42 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router'; import { ItemPageResolver } from './item-page.resolver'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; +import { VersionResolver } from './version-page/version.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; @@ -12,6 +13,9 @@ import { MenuItemType } from '../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; +import { VersionPageComponent } from './version-page/version-page/version-page.component'; +import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; +import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; @NgModule({ imports: [ @@ -42,6 +46,10 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon path: UPLOAD_BITSTREAM_PATH, component: UploadBitstreamComponent, canActivate: [AuthenticatedGuard] + }, + { + path: REQUEST_COPY_MODULE_PATH, + component: BitstreamRequestACopyPageComponent, } ], data: { @@ -58,6 +66,18 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon }], }, }, + }, + { + path: 'version', + children: [ + { + path: ':id', + component: VersionPageComponent, + resolve: { + dso: VersionResolver, + }, + } + ], } ]) ], @@ -67,6 +87,7 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon DSOBreadcrumbsService, LinkService, ItemPageAdministratorGuard, + VersionResolver, ] }) diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 1c6cd83e9c..2e3e248692 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -12,7 +12,6 @@ import { ItemPageAbstractFieldComponent } from './simple/field-components/specif import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component'; -import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; @@ -31,6 +30,11 @@ import { MediaViewerComponent } from './media-viewer/media-viewer.component'; import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component'; import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component'; import { NgxGalleryModule } from '@kolkov/ngx-gallery'; +import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component'; +import { VersionPageComponent } from './version-page/version-page/version-page.component'; +import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component'; +import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component'; + const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -39,6 +43,7 @@ const ENTRY_COMPONENTS = [ ]; const DECLARATIONS = [ + ThemedFileSectionComponent, ItemPageComponent, ThemedItemPageComponent, FullItemPageComponent, @@ -50,7 +55,6 @@ const DECLARATIONS = [ ItemPageUriFieldComponent, ItemPageTitleFieldComponent, ItemPageFieldComponent, - FileSectionComponent, CollectionsComponent, FullFileSectionComponent, PublicationComponent, @@ -60,7 +64,9 @@ const DECLARATIONS = [ AbstractIncrementalListComponent, MediaViewerComponent, MediaViewerVideoComponent, - MediaViewerImageComponent + MediaViewerImageComponent, + MiradorViewerComponent, + VersionPageComponent, ]; @NgModule({ @@ -72,10 +78,11 @@ const DECLARATIONS = [ StatisticsModule.forRoot(), JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), - NgxGalleryModule, + NgxGalleryModule, ], declarations: [ - ...DECLARATIONS + ...DECLARATIONS, + VersionedItemComponent ], exports: [ ...DECLARATIONS diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.component.html b/src/app/item-page/mirador-viewer/mirador-viewer.component.html new file mode 100644 index 0000000000..fe8ede6cee --- /dev/null +++ b/src/app/item-page/mirador-viewer/mirador-viewer.component.html @@ -0,0 +1,4 @@ +

{{'iiifviewer.fullscreen.notice' | translate}}

+

{{viewerMessage}}

+ + diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.component.scss b/src/app/item-page/mirador-viewer/mirador-viewer.component.scss new file mode 100644 index 0000000000..9005102096 --- /dev/null +++ b/src/app/item-page/mirador-viewer/mirador-viewer.component.scss @@ -0,0 +1,13 @@ +#mirador-viewer { + border: 1px solid #dee2e6; + height: 660px; + width: 100% +} +.full-text-op { + text-align: right; + color: #333333; + font-size: 0.8em; +} +p.full-text-op { + margin-bottom: 0; +} diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts b/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts new file mode 100644 index 0000000000..645d2af91d --- /dev/null +++ b/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts @@ -0,0 +1,255 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MiradorViewerComponent } from './mirador-viewer.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MetadataMap } from '../../core/shared/metadata.models'; +import { Item } from '../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { of as observableOf } from 'rxjs'; +import { MiradorViewerService } from './mirador-viewer.service'; +import { HostWindowService } from '../../shared/host-window.service'; + + +function getItem(metadata: MetadataMap) { + return Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: metadata, + relationships: createRelationshipsObservable() + }); +} + +const noMetadata = new MetadataMap(); + +const mockHostWindowService = { + // This isn't really testing mobile status, the return observable just allows the test to run. + widthCategory: observableOf(true), +}; + +describe('MiradorViewerComponent with search', () => { + let comp: MiradorViewerComponent; + let fixture: ComponentFixture; + const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']); + + beforeEach(waitForAsync(() => { + viewerService.showEmbeddedViewer.and.returnValue(true); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [MiradorViewerComponent], + providers: [ + { provide: BitstreamDataService, useValue: {} }, + { provide: HostWindowService, useValue: mockHostWindowService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MiradorViewerComponent, { + set: { + providers: [ + { provide: MiradorViewerService, useValue: viewerService } + ] + } + }).compileComponents(); + })); + describe('searchable item', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MiradorViewerComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.searchable = true; + fixture.detectChanges(); + })); + + it('should set multi property to true', (() => { + expect(comp.multi).toBe(true); + })); + + it('should set url "multi" param to true', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer').src; + expect(value).toContain('multi=true'); + })); + + it('should set url "searchable" param to true', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer').src; + expect(value).toContain('searchable=true'); + })); + + it('should not call mirador service image count', () => { + expect(viewerService.getImageCount).not.toHaveBeenCalled(); + }); + + }); +}); + +describe('MiradorViewerComponent with multiple images', () => { + + let comp: MiradorViewerComponent; + let fixture: ComponentFixture; + const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']); + + beforeEach(waitForAsync(() => { + viewerService.showEmbeddedViewer.and.returnValue(true); + viewerService.getImageCount.and.returnValue(observableOf(2)); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [MiradorViewerComponent], + providers: [ + { provide: BitstreamDataService, useValue: {} }, + { provide: HostWindowService, useValue: mockHostWindowService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MiradorViewerComponent, { + set: { + providers: [ + { provide: MiradorViewerService, useValue: viewerService } + ] + } + }).compileComponents(); + })); + + describe('non-searchable item with multiple images', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MiradorViewerComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.searchable = false; + fixture.detectChanges(); + })); + + it('should set url "multi" param to true', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer').src; + expect(value).toContain('multi=true'); + })); + + it('should call mirador service image count', () => { + expect(viewerService.getImageCount).toHaveBeenCalled(); + }); + + it('should omit "searchable" param from url', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer').src; + expect(value).not.toContain('searchable=true'); + })); + + }); +}); + + +describe('MiradorViewerComponent with a single image', () => { + let comp: MiradorViewerComponent; + let fixture: ComponentFixture; + const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']); + + beforeEach(waitForAsync(() => { + viewerService.showEmbeddedViewer.and.returnValue(true); + viewerService.getImageCount.and.returnValue(observableOf(1)); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [MiradorViewerComponent], + providers: [ + { provide: BitstreamDataService, useValue: {} }, + { provide: HostWindowService, useValue: mockHostWindowService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MiradorViewerComponent, { + set: { + providers: [ + { provide: MiradorViewerService, useValue: viewerService } + ] + } + }).compileComponents(); + })); + + describe('single image viewer', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MiradorViewerComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + fixture.detectChanges(); + })); + + it('should omit "multi" param', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer').src; + expect(value).not.toContain('multi=false'); + })); + + it('should call mirador service image count', () => { + expect(viewerService.getImageCount).toHaveBeenCalled(); + }); + + }); + +}); + +describe('MiradorViewerComponent in development mode', () => { + let comp: MiradorViewerComponent; + let fixture: ComponentFixture; + const viewerService = jasmine.createSpyObj('MiradorViewerService', ['showEmbeddedViewer', 'getImageCount']); + + beforeEach(waitForAsync(() => { + viewerService.showEmbeddedViewer.and.returnValue(false); + viewerService.getImageCount.and.returnValue(observableOf(1)); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [MiradorViewerComponent], + providers: [ + { provide: BitstreamDataService, useValue: {} } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(MiradorViewerComponent, { + set: { + providers: [ + { provide: MiradorViewerService, useValue: viewerService }, + { provide: HostWindowService, useValue: mockHostWindowService } + ] + } + }).compileComponents(); + })); + + describe('embedded viewer', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MiradorViewerComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + fixture.detectChanges(); + })); + + it('should not embed the viewer', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#mirador-viewer'); + expect(value).toBeNull(); + })); + + it('should show message', (() => { + const value = fixture.debugElement + .nativeElement.querySelector('#viewer-message'); + expect(value).toBeDefined(); + })); + + }); +}); diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.component.ts b/src/app/item-page/mirador-viewer/mirador-viewer.component.ts new file mode 100644 index 0000000000..8876d2cea0 --- /dev/null +++ b/src/app/item-page/mirador-viewer/mirador-viewer.component.ts @@ -0,0 +1,135 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, OnInit, PLATFORM_ID } from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { Item } from '../../core/shared/item.model'; +import { environment } from '../../../environments/environment'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { Observable, of } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { isPlatformBrowser } from '@angular/common'; +import { MiradorViewerService } from './mirador-viewer.service'; +import { HostWindowService, WidthCategory } from '../../shared/host-window.service'; + +@Component({ + selector: 'ds-mirador-viewer', + styleUrls: ['./mirador-viewer.component.scss'], + templateUrl: './mirador-viewer.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ MiradorViewerService ] +}) +export class MiradorViewerComponent implements OnInit { + + @Input() object: Item; + + /** + * A previous dspace search query. + */ + @Input() query: string; + + /** + * True if searchable. + */ + @Input() searchable: boolean; + + /** + * Hides embedded viewer in dev mode. + */ + isViewerAvailable = true; + + /** + * The url for the iframe. + */ + iframeViewerUrl: Observable; + + /** + * Sets the viewer to show or hide thumbnail side navigation menu. + */ + multi = false; + + /** + * Hides the thumbnail navigation menu on smaller viewports. + */ + notMobile = false; + + viewerMessage = 'Sorry, the Mirador viewer is not currently available in development mode.'; + + constructor(private sanitizer: DomSanitizer, + private viewerService: MiradorViewerService, + private bitstreamDataService: BitstreamDataService, + private hostWindowService: HostWindowService, + @Inject(PLATFORM_ID) private platformId: any) { + } + + /** + * Creates the url for the Mirador iframe. Adds parameters for the displaying the search panel, query results, + * or multi-page thumbnail navigation. + */ + setURL() { + // The path to the REST manifest endpoint. + const manifestApiEndpoint = encodeURIComponent(environment.rest.baseUrl + '/iiif/' + + this.object.id + '/manifest'); + // The Express path to Mirador viewer. + let viewerPath = '/iiif/mirador/index.html?manifest=' + manifestApiEndpoint; + if (this.searchable) { + // Tell the viewer add search to menu. + viewerPath += '&searchable=' + this.searchable; + } + if (this.query) { + // Tell the viewer to execute a search for the query term. + viewerPath += '&query=' + this.query; + } + if (this.multi) { + // Tell the viewer to add thumbnail navigation. If searchable, thumbnail navigation is added by default. + viewerPath += '&multi=' + this.multi; + } + if (this.notMobile) { + viewerPath += '¬Mobile=true'; + } + + // TODO: Should the query term be trusted here? + return this.sanitizer.bypassSecurityTrustResourceUrl(viewerPath); + } + + ngOnInit(): void { + /** + * Initializes the iframe url observable. + */ + if (isPlatformBrowser(this.platformId)) { + + // Viewer is not currently available in dev mode so hide it in that case. + this.isViewerAvailable = this.viewerService.showEmbeddedViewer(); + + // The notMobile property affects the thumbnail navigation + // menu by hiding it for smaller viewports. This will not be + // responsive to resizing. + this.hostWindowService.widthCategory + .pipe(take(1)) + .subscribe((category: WidthCategory) => { + this.notMobile = !(category === WidthCategory.XS || category === WidthCategory.SM); + }); + + // We need to set the multi property to true if the + // item is searchable or when the ORIGINAL bundle contains more + // than 1 image. (The multi property determines whether the + // Mirador side thumbnail navigation panel is shown.) + if (this.searchable) { + this.multi = true; + const observable = of(''); + this.iframeViewerUrl = observable.pipe( + map((val) => { + return this.setURL(); + }) + ); + } else { + // Sets the multi value based on the image count. + this.iframeViewerUrl = this.viewerService.getImageCount(this.object, this.bitstreamDataService).pipe( + map(c => { + if (c > 1) { + this.multi = true; + } + return this.setURL(); + }) + ); + } + } + } +} diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.service.ts b/src/app/item-page/mirador-viewer/mirador-viewer.service.ts new file mode 100644 index 0000000000..4bb095b89f --- /dev/null +++ b/src/app/item-page/mirador-viewer/mirador-viewer.service.ts @@ -0,0 +1,57 @@ +import { Injectable, isDevMode } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Item } from '../../core/shared/item.model'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { last, map, switchMap } from 'rxjs/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + +@Injectable() +export class MiradorViewerService { + + LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('format'), + ]; + + /** + * Returns boolean to hide viewer when running in development mode. + * Needed until it's possible to embed the viewer in development builds. + */ + showEmbeddedViewer (): boolean { + return !isDevMode(); + } + + /** + * Returns observable of the number of images in the ORIGINAL bundle + * @param item + * @param bitstreamDataService + */ + getImageCount(item: Item, bitstreamDataService: BitstreamDataService): Observable { + let count = 0; + return bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL', { + currentPage: 1, + elementsPerPage: 10 + }, true, true, ...this.LINKS_TO_FOLLOW) + .pipe( + getFirstCompletedRemoteData(), + map((bitstreamsRD: RemoteData>) => bitstreamsRD.payload), + map((paginatedList: PaginatedList) => paginatedList.page), + switchMap((bitstreams: Bitstream[]) => bitstreams), + switchMap((bitstream: Bitstream) => bitstream.format.pipe( + getFirstSucceededRemoteDataPayload(), + map((format: BitstreamFormat) => format) + )), + map((format: BitstreamFormat) => { + if (format.mimetype.includes('image')) { + count++; + } + return count; + }), + last() + ); + } +} diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 0fa5daa012..3d093f83c9 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -1,7 +1,7 @@
- + {{file?.name}} ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts b/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts new file mode 100644 index 0000000000..ba5a9e87c0 --- /dev/null +++ b/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts @@ -0,0 +1,28 @@ +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; +import { FileSectionComponent } from './file-section.component'; +import {Component, Input} from '@angular/core'; +import {Item} from '../../../../core/shared/item.model'; + +@Component({ + selector: 'ds-themed-item-page-file-section', + templateUrl: '../../../../shared/theme-support/themed.component.html', +}) +export class ThemedFileSectionComponent extends ThemedComponent { + + @Input() item: Item; + + protected inAndOutputNames: (keyof FileSectionComponent & keyof this)[] = ['item']; + + protected getComponentName(): string { + return 'FileSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/item-page/simple/field-components/file-section/file-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./file-section.component`); + } + +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index e843155d10..74b61fd976 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -5,7 +5,7 @@ - +
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 9e61f00e48..bace9fcd0a 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -1,3 +1,12 @@ +
+
+ + +
+

{{'publication.page.titleprefix' | translate}} @@ -16,7 +25,7 @@ - + { let comp: PublicationComponent; @@ -68,6 +86,7 @@ describe('PublicationComponent', () => { { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: RouteService, useValue: mockRouteService } ], schemas: [NO_ERRORS_SCHEMA] @@ -76,41 +95,81 @@ describe('PublicationComponent', () => { }).compileComponents(); })); - beforeEach(waitForAsync(() => { - fixture = TestBed.createComponent(PublicationComponent); - comp = fixture.componentInstance; - comp.object = mockItem; - fixture.detectChanges(); - })); + describe('default view', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + fixture.detectChanges(); + })); - it('should contain a component to display the date', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); + it('should contain a component to display the date', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should not contain a metadata only author field', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); + expect(fields.length).toBe(0); + }); + + it('should contain a mixed metadata and relationship field for authors', () => { + const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field')); + expect(fields.length).toBe(1); + }); + + it('should contain a component to display the abstract', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the uri', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the collections', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); }); - it('should not contain a metadata only author field', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); - expect(fields.length).toBe(0); + describe('with IIIF viewer', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledMap); + fixture.detectChanges(); + })); + + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + }); - it('should contain a mixed metadata and relationship field for authors', () => { - const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field')); - expect(fields.length).toBe(1); - }); + describe('with IIIF viewer and search', () => { - it('should contain a component to display the abstract', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); - }); + beforeEach(waitForAsync(() => { + mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item'])); + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledWithSearchMap); + fixture.detectChanges(); + })); - it('should contain a component to display the uri', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); - }); + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should call the RouteService getHistory method', () => { + expect(mockRouteService.getPreviousUrl).toHaveBeenCalled(); + }); - it('should contain a component to display the collections', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); - expect(fields.length).toBeGreaterThanOrEqual(1); }); }); + diff --git a/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts b/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts new file mode 100644 index 0000000000..eb7b30eb56 --- /dev/null +++ b/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts @@ -0,0 +1,35 @@ +import { Item } from '../../../../core/shared/item.model'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { RouteService } from '../../../../core/services/route.service'; + +export const isIiifEnabled = (item: Item) => { + return !!item.firstMetadataValue('dspace.iiif.enabled'); + +}; + +export const isIiifSearchEnabled = (item: Item) => { + return !!item.firstMetadataValue('iiif.search.enabled'); + +}; + +/** + * Checks to see if previous route was a dspace search. If + * it was, the search term is extracted and subsequently passed + * to the mirador viewer component. + * @param item the dspace object + * @param routeService + */ +export const getDSpaceQuery = (item: Item, routeService: RouteService): Observable => { + return routeService.getPreviousUrl().pipe( + filter(r => { + return r.includes('/search'); + }), + map(r => { + const arr = r.split('&'); + const q = arr[1]; + const v = q.split('='); + return v[1]; + }) + ); +}; diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index 8e444d844b..fc07f60b28 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -30,6 +30,26 @@ import { GenericItemPageFieldComponent } from '../../field-components/specific-f import { compareArraysUsing, compareArraysUsingIds } from './item-relationships-utils'; import { ItemComponent } from './item.component'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { RouteService } from '../../../../core/services/route.service'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; + +export const iiifEnabled = Object.assign(new MetadataValue(),{ + 'value': 'true', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 +}); + +export const iiifSearchEnabled = Object.assign(new MetadataValue(), { + 'value': 'true', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 +}); + +export const mockRouteService = jasmine.createSpyObj('RouteService', ['getPreviousUrl']); /** * Create a generic test for an item-page-fields component using a mockItem and the type of component @@ -72,6 +92,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) { { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: RouteService, useValue: {} } ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/item-page/simple/item-types/shared/item.component.ts b/src/app/item-page/simple/item-types/shared/item.component.ts index 793af180c9..fa7fba8724 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.ts @@ -2,6 +2,9 @@ import { Component, Input, OnInit } from '@angular/core'; import { environment } from '../../../../../environments/environment'; import { Item } from '../../../../core/shared/item.model'; import { getItemPageRoute } from '../../../item-page-routing-paths'; +import { RouteService } from '../../../../core/services/route.service'; +import { Observable } from 'rxjs'; +import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils'; @Component({ selector: 'ds-item', @@ -18,9 +21,33 @@ export class ItemComponent implements OnInit { */ itemPageRoute: string; + /** + * Enables the mirador component. + */ + iiifEnabled: boolean; + + /** + * Used to configure search in mirador. + */ + iiifSearchEnabled: boolean; + + /** + * The query term from the previous dspace search. + */ + iiifQuery$: Observable; + mediaViewer = environment.mediaViewer; + constructor(protected routeService: RouteService) { + } + ngOnInit(): void { this.itemPageRoute = getItemPageRoute(this.object); + // check to see if iiif viewer is required. + this.iiifEnabled = isIiifEnabled(this.object); + this.iiifSearchEnabled = isIiifSearchEnabled(this.object); + if (this.iiifSearchEnabled) { + this.iiifQuery$ = getDSpaceQuery(this.object, this.routeService); + } } } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index b0157dcfee..04794717f1 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -1,8 +1,20 @@ +
+
+ + +
+

+
@@ -16,7 +28,7 @@ - + { let comp: UntypedItemComponent; @@ -47,13 +72,16 @@ describe('UntypedItemComponent', () => { } }; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], + declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe ], providers: [ { provide: ItemDataService, useValue: {} }, { provide: TruncatableService, useValue: {} }, @@ -68,50 +96,101 @@ describe('UntypedItemComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: ItemVersionsSharedService, useValue: {} }, + { provide: RouteService, useValue: mockRouteService } ], - schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(UntypedItemComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } + set: {changeDetection: ChangeDetectionStrategy.Default} }).compileComponents(); })); - beforeEach(waitForAsync(() => { - fixture = TestBed.createComponent(UntypedItemComponent); - comp = fixture.componentInstance; - comp.object = mockItem; - fixture.detectChanges(); - })); + describe('default view', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + fixture.detectChanges(); + })); - it('should contain a component to display the date', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); + it('should contain a component to display the date', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-date-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should not contain a metadata only author field', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); + expect(fields.length).toBe(0); + }); + + it('should contain a mixed metadata and relationship field for authors', () => { + const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field')); + expect(fields.length).toBe(1); + }); + + it('should contain a component to display the abstract', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the uri', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should contain a component to display the collections', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should not contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBe(0); + }); }); - it('should not contain a metadata only author field', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-author-field')); - expect(fields.length).toBe(0); + + describe('with IIIF viewer', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledMap); + fixture.detectChanges(); + })); + + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + }); - it('should contain a mixed metadata and relationship field for authors', () => { - const fields = fixture.debugElement.queryAll(By.css('.ds-item-page-mixed-author-field')); - expect(fields.length).toBe(1); - }); + describe('with IIIF viewer and search', () => { - it('should contain a component to display the abstract', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-abstract-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); - }); + beforeEach(waitForAsync(() => { + mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item'])); + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledWithSearchMap); + fixture.detectChanges(); + })); - it('should contain a component to display the uri', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-uri-field')); - expect(fields.length).toBeGreaterThanOrEqual(1); - }); + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + + it('should call the RouteService getHistory method', () => { + expect(mockRouteService.getPreviousUrl).toHaveBeenCalled(); + }); - it('should contain a component to display the collections', () => { - const fields = fixture.debugElement.queryAll(By.css('ds-item-page-collections')); - expect(fields.length).toBeGreaterThanOrEqual(1); }); }); diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 3183c42a28..3ce33dc90a 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; -import { ItemComponent } from '../shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../versioned-item/versioned-item.component'; /** * Component that represents a publication Item page @@ -15,6 +15,6 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh templateUrl: './untyped-item.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UntypedItemComponent extends ItemComponent { +export class UntypedItemComponent extends VersionedItemComponent { } diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts new file mode 100644 index 0000000000..eff51b1019 --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VersionedItemComponent } from './versioned-item.component'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; +import { Item } from '../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; +import { createRelationshipsObservable, mockRouteService } from '../shared/item.component.spec'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Version } from '../../../../core/shared/version.model'; +import { RouteService } from '../../../../core/services/route.service'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: new MetadataMap(), + relationships: createRelationshipsObservable(), + _links: { + self: { + href: 'item-href' + }, + version: { + href: 'version-href' + } + } +}); + + +@Component({template: ''}) +class DummyComponent { +} + +describe('VersionedItemComponent', () => { + let component: VersionedItemComponent; + let fixture: ComponentFixture; + + let versionService: VersionDataService; + let versionHistoryService: VersionHistoryDataService; + + const versionServiceSpy = jasmine.createSpyObj('versionService', { + findByHref: createSuccessfulRemoteDataObject$(new Version()), + }); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { + createVersion: createSuccessfulRemoteDataObject$(new Version()), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [VersionedItemComponent, DummyComponent], + imports: [RouterTestingModule], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, + { provide: TranslateService, useValue: {} }, + { provide: VersionDataService, useValue: versionServiceSpy }, + { provide: NotificationsService, useValue: {} }, + { provide: ItemVersionsSharedService, useValue: {} }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: RouteService, useValue: mockRouteService } + ] + }).compileComponents(); + versionService = TestBed.inject(VersionDataService); + versionHistoryService = TestBed.inject(VersionHistoryDataService); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VersionedItemComponent); + component = fixture.componentInstance; + component.object = mockItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('when onCreateNewVersion() is called', () => { + it('should call versionService.findByHref', () => { + component.onCreateNewVersion(); + expect(versionService.findByHref).toHaveBeenCalledWith('version-href'); + }); + }); + +}); diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts new file mode 100644 index 0000000000..cd2eb3a19b --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts @@ -0,0 +1,80 @@ +import { Component } from '@angular/core'; +import { ItemComponent } from '../shared/item.component'; +import { ItemVersionsSummaryModalComponent } from '../../../../shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Version } from '../../../../core/shared/version.model'; +import { switchMap, tap } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; +import { Router } from '@angular/router'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; +import { RouteService } from '../../../../core/services/route.service'; + +@Component({ + selector: 'ds-versioned-item', + templateUrl: './versioned-item.component.html', + styleUrls: ['./versioned-item.component.scss'] +}) +export class VersionedItemComponent extends ItemComponent { + + constructor( + private modalService: NgbModal, + private versionHistoryService: VersionHistoryDataService, + private translateService: TranslateService, + private versionService: VersionDataService, + private itemVersionShared: ItemVersionsSharedService, + private router: Router, + private workspaceItemDataService: WorkspaceitemDataService, + private searchService: SearchService, + private itemService: ItemDataService, + protected routeService: RouteService + ) { + super(routeService); + } + + /** + * Open a modal that allows to create a new version starting from the specified item, with optional summary + */ + onCreateNewVersion(): void { + + const item = this.object; + const versionHref = item._links.version.href; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + + // Show current version in modal + this.versionService.findByHref(versionHref).pipe(getFirstCompletedRemoteData()).subscribe((res: RemoteData) => { + // if res.hasNoContent then the item is unversioned + activeModal.componentInstance.firstVersion = res.hasNoContent; + activeModal.componentInstance.versionNumber = (res.hasNoContent ? undefined : res.payload.version); + }); + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + getFirstCompletedRemoteData(), + // show success/failure notification + tap((res: RemoteData) => { this.itemVersionShared.notifyCreateNewVersion(res); }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + + } +} diff --git a/src/app/item-page/version-page/version-page/version-page.component.html b/src/app/item-page/version-page/version-page/version-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.scss b/src/app/item-page/version-page/version-page/version-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.spec.ts b/src/app/item-page/version-page/version-page/version-page.component.spec.ts new file mode 100644 index 0000000000..b1dd8bc161 --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VersionPageComponent } from './version-page.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { createRelationshipsObservable } from '../../simple/item-types/shared/item.component.spec'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { Version } from '../../../core/shared/version.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: [], + relationships: createRelationshipsObservable(), + uuid: 'item-uuid', +}); + +const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(mockItem), + version: 1, +}); + +@Component({ template: '' }) +class DummyComponent { +} + +describe('VersionPageComponent', () => { + let component: VersionPageComponent; + let fixture: ComponentFixture; + let authService: AuthService; + + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: observableOf({dso: createSuccessfulRemoteDataObject(mockVersion)}) + }); + + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + TestBed.configureTestingModule({ + declarations: [VersionPageComponent, DummyComponent], + imports: [RouterTestingModule.withRoutes([{ path: 'items/item-uuid', component: DummyComponent, pathMatch: 'full' }])], + providers: [ + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: VersionDataService, useValue: {} }, + { provide: AuthService, useValue: authService }, + ], + }).compileComponents(); + })); + + + + beforeEach(() => { + fixture = TestBed.createComponent(VersionPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/version-page/version-page/version-page.component.ts b/src/app/item-page/version-page/version-page/version-page.component.ts new file mode 100644 index 0000000000..0a2021e06d --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../../core/auth/auth.service'; +import { map, switchMap } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../../core/shared/operators'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { Version } from '../../../core/shared/version.model'; +import { Item } from '../../../core/shared/item.model'; +import { getItemPageRoute } from '../../item-page-routing-paths'; +import { getPageNotFoundRoute } from '../../../app-routing-paths'; + +@Component({ + selector: 'ds-version-page', + templateUrl: './version-page.component.html', + styleUrls: ['./version-page.component.scss'] +}) +export class VersionPageComponent implements OnInit { + + versionRD$: Observable>; + itemRD$: Observable>; + + constructor( + protected route: ActivatedRoute, + private router: Router, + private versionService: VersionDataService, + private authService: AuthService, + ) { + } + + ngOnInit(): void { + /* Retrieve version from resolver or redirect on 4xx */ + this.versionRD$ = this.route.data.pipe( + map((data) => data.dso as RemoteData), + redirectOn4xx(this.router, this.authService), + ); + + /* Retrieve item from version and reroute to item's page or handle missing item */ + this.versionRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => version.item), + redirectOn4xx(this.router, this.authService), + getFirstCompletedRemoteData(), + ).subscribe((itemRD) => { + if (itemRD.hasNoContent) { + this.router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true }); + } else { + const itemUrl = getItemPageRoute(itemRD.payload); + this.router.navigateByUrl(itemUrl); + } + }); + + } + +} diff --git a/src/app/item-page/version-page/version.resolver.ts b/src/app/item-page/version-page/version.resolver.ts new file mode 100644 index 0000000000..8341052468 --- /dev/null +++ b/src/app/item-page/version-page/version.resolver.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { Version } from '../../core/shared/version.model'; +import { VersionDataService } from '../../core/data/version-data.service'; + +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ +export const VERSION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('item'), +]; + +/** + * This class represents a resolver that requests a specific version before the route is activated + */ +@Injectable() +export class VersionResolver implements Resolve> { + constructor( + protected versionService: VersionDataService, + protected store: Store, + protected router: Router + ) { + } + + /** + * Method for resolving a version based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const versionRD$ = this.versionService.findById(route.params.id, + true, + false, + ...VERSION_PAGE_LINKS_TO_FOLLOW + ).pipe( + getFirstCompletedRemoteData(), + ); + + versionRD$.subscribe((versionRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, versionRD.payload)); + }); + + return versionRD$; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html new file mode 100644 index 0000000000..141e628ac0 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html @@ -0,0 +1,24 @@ +
+ +
+
+ + +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss new file mode 100644 index 0000000000..a156132e3f --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss @@ -0,0 +1,16 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} + +#entityControlsDropdownMenu { + min-width: 18rem; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts new file mode 100644 index 0000000000..aa223fc10e --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts @@ -0,0 +1,189 @@ +import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { createPaginatedList, createTestComponent } from '../../../shared/testing/utils.test'; +import { MyDSpaceNewExternalDropdownComponent } from './my-dspace-new-external-dropdown.component'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { RouterStub } from '../../../shared/testing/router.stub'; + +export function getMockEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 4, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type2: ItemType = { + id: '2', + label: 'Journal', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type3: ItemType = { + id: '2', + label: 'DataPackage', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1, type2, type3])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipTypeImport: rd$, + hasMoreThanOneAuthorizedImport: observableOf(true) + }); +} + +export function getMockEmptyEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 1, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipTypeImport: rd$, + hasMoreThanOneAuthorizedImport: observableOf(false) + }); +} + +describe('MyDSpaceNewExternalDropdownComponent test', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + let submissionComponent: MyDSpaceNewExternalDropdownComponent; + let submissionComponentFixture: ComponentFixture; + + const entityType1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + + describe('With only one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewExternalDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, + { provide: Router, useValue: new RouterStub() }, + MyDSpaceNewExternalDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewExternalDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewExternalDropdownComponent', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a single button', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + submissionComponentFixture.detectChanges(); + const addDivElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.add')); + const addDiv = addDivElement.nativeElement; + expect(addDiv.innerHTML).toBeDefined(); + const buttonElement: DebugElement = addDivElement.query(By.css('.btn')); + const button = buttonElement.nativeElement; + expect(button.innerHTML).toBeDefined(); + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + expect(dropdownElement).toBeNull(); + })); + }); + + describe('With more than one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewExternalDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, + { provide: Router, useValue: new RouterStub() }, + MyDSpaceNewExternalDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewExternalDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewExternalDropdownComponent', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a dropdown button', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + const dropdown = dropdownElement.nativeElement; + expect(dropdown.innerHTML).toBeDefined(); + })); + + it('should invoke modalService.open', () => { + submissionComponent.openPage(entityType1); + + expect((submissionComponent as any).router.navigate).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + reload = (event) => { + return; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts new file mode 100644 index 0000000000..651178e7a1 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts @@ -0,0 +1,110 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; + +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { FindListOptions } from '../../../core/data/request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; + +/** + * This component represents the 'Import metadata from external source' dropdown menu + */ +@Component({ + selector: 'ds-my-dspace-new-external-dropdown', + styleUrls: ['./my-dspace-new-external-dropdown.component.scss'], + templateUrl: './my-dspace-new-external-dropdown.component.html' +}) +export class MyDSpaceNewExternalDropdownComponent implements OnInit, OnDestroy { + + /** + * Used to verify if there are one or more entities available + */ + public moreThanOne$: Observable; + + /** + * The entity observble (only if there is only one entity available) + */ + public singleEntity$: Observable; + + /** + * The entity object (only if there is only one entity available) + */ + public singleEntity: ItemType; + + /** + * TRUE if the page is initialized + */ + public initialized$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {EntityTypeService} entityTypeService + * @param {Router} router + */ + constructor(private entityTypeService: EntityTypeService, + private router: Router) { } + + /** + * Initialize entity type list + */ + ngOnInit() { + this.initialized$ = observableOf(false); + this.moreThanOne$ = this.entityTypeService.hasMoreThanOneAuthorizedImport(); + this.singleEntity$ = this.moreThanOne$.pipe( + mergeMap((response: boolean) => { + if (!response) { + const findListOptions: FindListOptions = { + elementsPerPage: 1, + currentPage: 1 + }; + return this.entityTypeService.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( + map((entities: RemoteData>) => { + this.initialized$ = observableOf(true); + return entities.payload.page[0]; + }), + take(1) + ); + } else { + this.initialized$ = observableOf(true); + return observableOf(null); + } + }), + take(1) + ); + this.subs.push( + this.singleEntity$.subscribe((result) => this.singleEntity = result ) + ); + } + + /** + * Method called on clicking the button 'Import metadata from external source'. It opens the page of the external import. + */ + openPage(entity: ItemType) { + const params = Object.create({}); + if (entity) { + params.entity = entity.label; + } + this.router.navigate(['/import-external'], { queryParams: params }); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html new file mode 100644 index 0000000000..ac40bbb005 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html @@ -0,0 +1,20 @@ +
+ +
+
+ + +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss new file mode 100644 index 0000000000..a156132e3f --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss @@ -0,0 +1,16 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} + +#entityControlsDropdownMenu { + min-width: 18rem; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts new file mode 100644 index 0000000000..2e7361c560 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts @@ -0,0 +1,194 @@ +import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; +import { createPaginatedList, createTestComponent } from '../../../shared/testing/utils.test'; +import { MyDSpaceNewSubmissionDropdownComponent } from './my-dspace-new-submission-dropdown.component'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { PageInfo } from '../../../core/shared/page-info.model'; + +export function getMockEntityTypeService(): EntityTypeService { + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type2: ItemType = { + id: '2', + label: 'Journal', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type3: ItemType = { + id: '2', + label: 'DataPackage', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1, type2, type3])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipType: rd$, + hasMoreThanOneAuthorized: observableOf(true) + }); +} + +export function getMockEmptyEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 1, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipType: rd$, + hasMoreThanOneAuthorized: observableOf(false) + }); +} + +describe('MyDSpaceNewSubmissionDropdownComponent test', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + let submissionComponent: MyDSpaceNewSubmissionDropdownComponent; + let submissionComponentFixture: ComponentFixture; + + const entityType1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + + const modalStub = { + open: () => null, + close: () => null, + dismiss: () => null + }; + + describe('With only one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewSubmissionDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, + { provide: NgbModal, useValue: modalStub }, + MyDSpaceNewSubmissionDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewSubmissionDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionDropdownComponent', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a single button', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + submissionComponentFixture.detectChanges(); + const addDivElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.add')); + const addDiv = addDivElement.nativeElement; + expect(addDiv.innerHTML).toBeDefined(); + const buttonElement: DebugElement = addDivElement.query(By.css('.btn')); + const button = buttonElement.nativeElement; + expect(button.innerHTML).toBeDefined(); + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + expect(dropdownElement).toBeNull(); + })); + }); + + describe('With more than one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewSubmissionDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, + { provide: NgbModal, useValue: modalStub }, + MyDSpaceNewSubmissionDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewSubmissionDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionDropdownComponent', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a dropdown button', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + const dropdown = dropdownElement.nativeElement; + expect(dropdown.innerHTML).toBeDefined(); + })); + + it('should invoke modalService.open', () => { + spyOn((submissionComponent as any).modalService, 'open').and.returnValue({ componentInstance: { } }); + submissionComponent.openDialog(entityType1); + + expect((submissionComponent as any).modalService.open).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + reload = (event) => { + return; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts new file mode 100644 index 0000000000..0ff363b164 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts @@ -0,0 +1,109 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { FindListOptions } from '../../../core/data/request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { CreateItemParentSelectorComponent } from '../../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; + +/** + * This component represents the new submission dropdown + */ +@Component({ + selector: 'ds-my-dspace-new-submission-dropdown', + styleUrls: ['./my-dspace-new-submission-dropdown.component.scss'], + templateUrl: './my-dspace-new-submission-dropdown.component.html' +}) +export class MyDSpaceNewSubmissionDropdownComponent implements OnInit, OnDestroy { + + /** + * Used to verify if there are one or more entities available + */ + public moreThanOne$: Observable; + + /** + * The entity observble (only if there is only one entity available) + */ + public singleEntity$: Observable; + + /** + * The entity object (only if there is only one entity available) + */ + public singleEntity: ItemType; + + /** + * TRUE if the page is initialized + */ + public initialized$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {EntityTypeService} entityTypeService + * @param {NgbModal} modalService + */ + constructor(private entityTypeService: EntityTypeService, + private modalService: NgbModal) { } + + /** + * Initialize entity type list + */ + ngOnInit() { + this.initialized$ = observableOf(false); + this.moreThanOne$ = this.entityTypeService.hasMoreThanOneAuthorized(); + this.singleEntity$ = this.moreThanOne$.pipe( + mergeMap((response: boolean) => { + if (!response) { + const findListOptions: FindListOptions = { + elementsPerPage: 1, + currentPage: 1 + }; + return this.entityTypeService.getAllAuthorizedRelationshipType(findListOptions).pipe( + map((entities: RemoteData>) => { + this.initialized$ = observableOf(true); + return entities.payload.page[0]; + }), + take(1) + ); + } else { + this.initialized$ = observableOf(true); + return observableOf(null); + } + }), + take(1) + ); + this.subs.push( + this.singleEntity$.subscribe((result) => this.singleEntity = result ) + ); + } + + /** + * Method called on clicking the button "New Submition", It opens a dialog for + * select a collection. + */ + openDialog(entity: ItemType) { + const modalRef = this.modalService.open(CreateItemParentSelectorComponent); + modalRef.componentInstance.entityType = entity.label; + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html index 028b7df5a5..d0052b9355 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -8,14 +8,10 @@

- +
- - - +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index 7c6d8918cb..fb8ecbf65c 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -1,7 +1,6 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; @@ -25,6 +24,8 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http'; import { CookieService } from '../../core/services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; +import { getMockEntityTypeService } from './my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec'; +import { EntityTypeService } from '../../core/data/entity-type.service'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -62,6 +63,7 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, { provide: CookieService, useValue: new CookieServiceMock() }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -104,20 +106,6 @@ describe('MyDSpaceNewSubmissionComponent test', () => { comp.uploaderComponent.uploader = uploader; }); - it('should call app.openDialog', (done) => { - spyOn(comp, 'openDialog'); - const submissionButton = fixture.debugElement.query(By.css('button.btn-primary')); - submissionButton.triggerEventHandler('click', null); - - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(comp.openDialog).toHaveBeenCalled(); - done(); - }); - - }); - it('should show a collection selector if only one file are uploaded', (done) => { spyOn((comp as any).modalService, 'open').and.returnValue({ result: new Promise((res, rej) => {/****/}) }); comp.afterFileLoaded(['']); diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index c1e67561b2..580afd8ad4 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -14,7 +14,6 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { NotificationType } from '../../shared/notifications/models/notification-type'; import { hasValue } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; -import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component'; import { UploaderComponent } from '../../shared/uploader/uploader.component'; import { UploaderError } from '../../shared/uploader/uploader-error.model'; @@ -118,14 +117,6 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { this.notificationsService.error(null, this.translate.get(errorMessageKey)); } - /** - * Method called on clicking the button "New Submition", It opens a dialog for - * select a collection. - */ - openDialog() { - this.modalService.open(CreateItemParentSelectorComponent); - } - /** * Method invoked after all file are loaded from upload plugin */ diff --git a/src/app/my-dspace-page/my-dspace-page.component.html b/src/app/my-dspace-page/my-dspace-page.component.html index 32e3a0d710..4aadb16255 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.html +++ b/src/app/my-dspace-page/my-dspace-page.component.html @@ -15,7 +15,7 @@ [query]="(searchOptions$ | async)?.query" [scope]="(searchOptions$ | async)?.scope" [currentUrl]="getSearchLink()" - [scopes]="(scopeListRD$ | async)" + [showScopeSelector]="true" [inPlaceSearch]="inPlaceSearch" [searchPlaceholder]="'mydspace.search-form.placeholder' | translate"> diff --git a/src/app/my-dspace-page/my-dspace-page.component.ts b/src/app/my-dspace-page/my-dspace-page.component.ts index 3ded17191e..90163abc5e 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.ts +++ b/src/app/my-dspace-page/my-dspace-page.component.ts @@ -78,11 +78,6 @@ export class MyDSpacePageComponent implements OnInit { */ sortOptions$: Observable; - /** - * The current relevant scopes - */ - scopeListRD$: Observable; - /** * Emits true if were on a small screen */ @@ -144,10 +139,6 @@ export class MyDSpacePageComponent implements OnInit { this.resultsRD$.next(results); }); - this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( - switchMap((scopeId) => this.service.getScopes(scopeId)) - ); - this.context$ = this.searchConfigService.getCurrentConfiguration('workspace') .pipe( map((configuration: string) => { diff --git a/src/app/my-dspace-page/my-dspace-page.module.ts b/src/app/my-dspace-page/my-dspace-page.module.ts index 52c80c90b0..a5a18effbc 100644 --- a/src/app/my-dspace-page/my-dspace-page.module.ts +++ b/src/app/my-dspace-page/my-dspace-page.module.ts @@ -11,6 +11,8 @@ import { MyDSpaceGuard } from './my-dspace.guard'; import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; import { CollectionSelectorComponent } from './collection-selector/collection-selector.component'; import { MyDspaceSearchModule } from './my-dspace-search.module'; +import { MyDSpaceNewSubmissionDropdownComponent } from './my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component'; +import { MyDSpaceNewExternalDropdownComponent } from './my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component'; import { ThemedMyDSpacePageComponent } from './themed-my-dspace-page.component'; const DECLARATIONS = [ @@ -18,7 +20,9 @@ const DECLARATIONS = [ ThemedMyDSpacePageComponent, MyDSpaceResultsComponent, MyDSpaceNewSubmissionComponent, - CollectionSelectorComponent + CollectionSelectorComponent, + MyDSpaceNewSubmissionDropdownComponent, + MyDSpaceNewExternalDropdownComponent ]; @NgModule({ diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index bfefcb5a6c..08d1b54fab 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -1,5 +1,8 @@ - + diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts index 1cc138b53e..488c9ab251 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -9,6 +9,7 @@ import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { VarDirective } from '../../shared/utils/var.directive'; describe('ExpandableNavbarSectionComponent', () => { let component: ExpandableNavbarSectionComponent; @@ -19,7 +20,7 @@ describe('ExpandableNavbarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], - declarations: [ExpandableNavbarSectionComponent, TestComponent], + declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, @@ -49,7 +50,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when the mouse enters the section header', () => { beforeEach(() => { spyOn(menuService, 'activateSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); sidebarToggler.triggerEventHandler('mouseenter', { preventDefault: () => {/**/ } @@ -64,7 +65,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when the mouse leaves the section header', () => { beforeEach(() => { spyOn(menuService, 'deactivateSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); sidebarToggler.triggerEventHandler('mouseleave', { preventDefault: () => {/**/ } @@ -76,10 +77,82 @@ describe('ExpandableNavbarSectionComponent', () => { }); }); + describe('when Enter key is pressed on section header (while inactive)', () => { + beforeEach(() => { + spyOn(menuService, 'activateSection'); + // Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); + // dispatch the (keyup.enter) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + }); + + it('should call activateSection on the menuService', () => { + expect(menuService.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when Enter key is pressed on section header (while active)', () => { + beforeEach(() => { + spyOn(menuService, 'deactivateSection'); + // Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); + // dispatch the (keyup.enter) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + }); + + it('should call deactivateSection on the menuService', () => { + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); + + describe('when spacebar is pressed on section header (while inactive)', () => { + beforeEach(() => { + spyOn(menuService, 'activateSection'); + // Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + }); + + it('should call activateSection on the menuService', () => { + expect(menuService.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when spacebar is pressed on section header (while active)', () => { + beforeEach(() => { + spyOn(menuService, 'deactivateSection'); + // Make sure section is 'active'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown')); + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + }); + + it('should call deactivateSection on the menuService', () => { + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); + describe('when a click occurs on the section header', () => { beforeEach(() => { spyOn(menuService, 'toggleActiveSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > a')); sidebarToggler.triggerEventHandler('click', { preventDefault: () => {/**/ } @@ -96,7 +169,7 @@ describe('ExpandableNavbarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], - declarations: [ExpandableNavbarSectionComponent, TestComponent], + declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, @@ -122,7 +195,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when the mouse enters the section header', () => { beforeEach(() => { spyOn(menuService, 'activateSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > a')); sidebarToggler.triggerEventHandler('mouseenter', { preventDefault: () => {/**/ } @@ -137,7 +210,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when the mouse leaves the section header', () => { beforeEach(() => { spyOn(menuService, 'deactivateSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > a')); sidebarToggler.triggerEventHandler('mouseleave', { preventDefault: () => {/**/ } @@ -152,7 +225,7 @@ describe('ExpandableNavbarSectionComponent', () => { describe('when a click occurs on the section header link', () => { beforeEach(() => { spyOn(menuService, 'toggleActiveSection'); - const sidebarToggler = fixture.debugElement.query(By.css('li.nav-item.dropdown')).query(By.css('a')); + const sidebarToggler = fixture.debugElement.query(By.css('div.nav-item.dropdown > a')); sidebarToggler.triggerEventHandler('click', { preventDefault: () => {/**/ } diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 068854d6f8..a0c0240159 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -11,7 +11,8 @@ import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator' * Represents an expandable section in the navbar */ @Component({ - selector: 'ds-expandable-navbar-section', + /* tslint:disable:component-selector */ + selector: 'li[ds-expandable-navbar-section]', templateUrl: './expandable-navbar-section.component.html', styleUrls: ['./expandable-navbar-section.component.scss'], animations: [slide] diff --git a/src/app/navbar/navbar-section/navbar-section.component.html b/src/app/navbar/navbar-section/navbar-section.component.html index 00461995bc..b5f6848050 100644 --- a/src/app/navbar/navbar-section/navbar-section.component.html +++ b/src/app/navbar/navbar-section/navbar-section.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/app/navbar/navbar-section/navbar-section.component.ts b/src/app/navbar/navbar-section/navbar-section.component.ts index e1488de3d3..89d1653bbc 100644 --- a/src/app/navbar/navbar-section/navbar-section.component.ts +++ b/src/app/navbar/navbar-section/navbar-section.component.ts @@ -8,7 +8,8 @@ import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator' * Represents a non-expandable section in the navbar */ @Component({ - selector: 'ds-navbar-section', + /* tslint:disable:component-selector */ + selector: 'li[ds-navbar-section]', templateUrl: './navbar-section.component.html', styleUrls: ['./navbar-section.component.scss'] }) diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index ef099d4f9f..ddf6a0aadc 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -2,7 +2,7 @@ @@ -23,11 +23,11 @@ -
{{ process.startTime }}
+
{{ process.startTime | date:dateFormat:'UTC' }}
-
{{ process.endTime }}
+
{{ process.endTime | date:dateFormat:'UTC' }}
@@ -35,7 +35,7 @@ - @@ -47,7 +47,7 @@

- diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index 481189cf05..b48afe5586 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -66,6 +66,11 @@ export class ProcessDetailComponent implements OnInit { */ retrievingOutputLogs$: BehaviorSubject; + /** + * Date format to use for start and end time of processes + */ + dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ'; + constructor(protected route: ActivatedRoute, protected router: Router, protected processService: ProcessDataService, diff --git a/src/app/process-page/overview/process-overview.component.html b/src/app/process-page/overview/process-overview.component.html index 62b1433b2c..7d3f15f074 100644 --- a/src/app/process-page/overview/process-overview.component.html +++ b/src/app/process-page/overview/process-overview.component.html @@ -26,8 +26,8 @@ {{process.processId}} {{process.scriptName}} {{ePersonName}} - {{process.startTime | date:dateFormat}} - {{process.endTime | date:dateFormat}} + {{process.startTime | date:dateFormat:'UTC'}} + {{process.endTime | date:dateFormat:'UTC'}} {{process.processStatus}} diff --git a/src/app/process-page/overview/process-overview.component.spec.ts b/src/app/process-page/overview/process-overview.component.spec.ts index 98e78f6b36..84ef4bca56 100644 --- a/src/app/process-page/overview/process-overview.component.spec.ts +++ b/src/app/process-page/overview/process-overview.component.spec.ts @@ -12,12 +12,9 @@ import { By } from '@angular/platform-browser'; import { ProcessStatus } from '../processes/process-status.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { of as observableOf } from 'rxjs'; import { PaginationService } from '../../core/pagination/pagination.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { DatePipe } from '@angular/common'; describe('ProcessOverviewComponent', () => { let component: ProcessOverviewComponent; @@ -30,27 +27,29 @@ describe('ProcessOverviewComponent', () => { let processes: Process[]; let ePerson: EPerson; + const pipe = new DatePipe('en-US'); + function init() { processes = [ Object.assign(new Process(), { processId: 1, scriptName: 'script-name', - startTime: '2020-03-19', - endTime: '2020-03-19', + startTime: '2020-03-19 00:30:00', + endTime: '2020-03-19 23:30:00', processStatus: ProcessStatus.COMPLETED }), Object.assign(new Process(), { processId: 2, scriptName: 'script-name', - startTime: '2020-03-20', - endTime: '2020-03-20', + startTime: '2020-03-20 00:30:00', + endTime: '2020-03-20 23:30:00', processStatus: ProcessStatus.FAILED }), Object.assign(new Process(), { processId: 3, scriptName: 'another-script-name', - startTime: '2020-03-21', - endTime: '2020-03-21', + startTime: '2020-03-21 00:30:00', + endTime: '2020-03-21 23:30:00', processStatus: ProcessStatus.RUNNING }) ]; @@ -135,14 +134,14 @@ describe('ProcessOverviewComponent', () => { it('should display the start time in the fourth column', () => { rowElements.forEach((rowElement, index) => { const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement; - expect(el.textContent).toContain(processes[index].startTime); + expect(el.textContent).toContain(pipe.transform(processes[index].startTime, component.dateFormat, 'UTC')); }); }); it('should display the end time in the fifth column', () => { rowElements.forEach((rowElement, index) => { const el = rowElement.query(By.css('td:nth-child(5)')).nativeElement; - expect(el.textContent).toContain(processes[index].endTime); + expect(el.textContent).toContain(pipe.transform(processes[index].endTime, component.dateFormat, 'UTC')); }); }); diff --git a/src/app/process-page/process-page.resolver.ts b/src/app/process-page/process-page.resolver.ts index ab3d491951..ba872302b3 100644 --- a/src/app/process-page/process-page.resolver.ts +++ b/src/app/process-page/process-page.resolver.ts @@ -23,7 +23,7 @@ export class ProcessPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.processService.findById(route.params.id, true, false, followLink('script')).pipe( + return this.processService.findById(route.params.id, false, true, followLink('script')).pipe( getFirstCompletedRemoteData(), ); } diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index 6fd0c8e4aa..0b2274f2d4 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -14,7 +14,8 @@ import { ThemedProfilePageComponent } from './themed-profile-page.component'; SharedModule ], exports: [ - ProfilePageSecurityFormComponent + ProfilePageSecurityFormComponent, + ProfilePageMetadataFormComponent ], declarations: [ ProfilePageComponent, diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.html b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html new file mode 100644 index 0000000000..b00bc079dd --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html @@ -0,0 +1,9 @@ +
+

{{'deny-request-copy.header' | translate}}

+
+

{{'deny-request-copy.intro' | translate}}

+ + +
+ +
diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss b/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..c88bfd3b5e --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { DenyRequestCopyComponent } from './deny-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; + +describe('DenyRequestCopyComponent', () => { + let component: DenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + deny: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [DenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('deny', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts new file mode 100644 index 0000000000..763319947a --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-deny-request-copy', + styleUrls: ['./deny-request-copy.component.scss'], + templateUrl: './deny-request-copy.component.html' +}) +/** + * Component for denying an item request + */ +export class DenyRequestCopyComponent implements OnInit { + /** + * The item request to deny + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('deny-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('deny-request-copy.email.message', params)), + ); + } + + /** + * Deny the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + deny(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.deny(itemRequest.token, email)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('deny-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('deny-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.html b/src/app/request-copy/email-request-copy/email-request-copy.component.html new file mode 100644 index 0000000000..d7633b0334 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.html @@ -0,0 +1,30 @@ +
+
+ + +
+ {{ 'grant-deny-request-copy.email.subject.empty' | translate }} +
+
+
+ + +
+ {{ 'grant-deny-request-copy.email.message.empty' | translate }} +
+
+ +
+ + +
+
diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.scss b/src/app/request-copy/email-request-copy/email-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts new file mode 100644 index 0000000000..3857c0d91b --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts @@ -0,0 +1,47 @@ +import { EmailRequestCopyComponent } from './email-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Location } from '@angular/common'; +import { RequestCopyEmail } from './request-copy-email.model'; + +describe('EmailRequestCopyComponent', () => { + let component: EmailRequestCopyComponent; + let fixture: ComponentFixture; + + let location: Location; + + beforeEach(waitForAsync(() => { + location = jasmine.createSpyObj('location', ['back']); + + TestBed.configureTestingModule({ + declarations: [EmailRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Location, useValue: location }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EmailRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('return should navigate to the previous page', () => { + component.return(); + expect(location.back).toHaveBeenCalled(); + }); + + it('submit should emit an email object', () => { + spyOn(component.send, 'emit').and.stub(); + component.subject = 'test-subject'; + component.message = 'test-message'; + component.submit(); + expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message')); + }); +}); diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.ts new file mode 100644 index 0000000000..ab2c8b4526 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RequestCopyEmail } from './request-copy-email.model'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'ds-email-request-copy', + styleUrls: ['./email-request-copy.component.scss'], + templateUrl: './email-request-copy.component.html' +}) +/** + * A form component for an email to send back to the user requesting an item + */ +export class EmailRequestCopyComponent { + /** + * Event emitter for sending the email + */ + @Output() send: EventEmitter = new EventEmitter(); + + /** + * The subject of the email + */ + @Input() subject: string; + + /** + * The contents of the email + */ + @Input() message: string; + + constructor(protected location: Location) { + } + + /** + * Submit the email + */ + submit() { + this.send.emit(new RequestCopyEmail(this.subject, this.message)); + } + + /** + * Return to the previous page + */ + return() { + this.location.back(); + } +} diff --git a/src/app/request-copy/email-request-copy/request-copy-email.model.ts b/src/app/request-copy/email-request-copy/request-copy-email.model.ts new file mode 100644 index 0000000000..3ae83e8815 --- /dev/null +++ b/src/app/request-copy/email-request-copy/request-copy-email.model.ts @@ -0,0 +1,8 @@ +/** + * A class representing an email to send back to the user requesting an item + */ +export class RequestCopyEmail { + constructor(public subject: string, + public message: string) { + } +} diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html new file mode 100644 index 0000000000..37b275d8f8 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html @@ -0,0 +1,30 @@ +
+

{{'grant-deny-request-copy.header' | translate}}

+
+ +
+

{{'grant-deny-request-copy.processed' | translate}}

+

+ {{'grant-deny-request-copy.home-page' | translate}} +

+
+
+ +
diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..5c37a86f24 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts @@ -0,0 +1,141 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { of as observableOf } from 'rxjs'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy.component'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { By } from '@angular/platform-browser'; + +describe('GrantDenyRequestCopyComponent', () => { + let component: GrantDenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + + let itemRequest: ItemRequest; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + itemName = 'item-name'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + itemUrl = getItemPageRoute(item); + + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + + TestBed.configureTestingModule({ + declarations: [GrantDenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantDenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + router = (component as any).router; + spyOn(router, 'navigateByUrl').and.stub(); + }); + + it('should initialise itemName$', (done) => { + component.itemName$.subscribe((result) => { + expect(result).toEqual(itemName); + done(); + }); + }); + + it('should initialise itemUrl$', (done) => { + component.itemUrl$.subscribe((result) => { + expect(result).toEqual(itemUrl); + done(); + }); + }); + + it('should initialise denyRoute$', (done) => { + component.denyRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyDenyRoute(itemRequest.token)); + done(); + }); + }); + + it('should initialise grantRoute$', (done) => { + component.grantRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyGrantRoute(itemRequest.token)); + done(); + }); + }); + + describe('processed message', () => { + it('should not be displayed when decisionDate is undefined', () => { + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).toBeNull(); + }); + + it('should be displayed when decisionDate is defined', () => { + component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, { + decisionDate: 'defined-date' + })); + fixture.detectChanges(); + + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).not.toBeNull(); + }); + }); +}); diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts new file mode 100644 index 0000000000..f9eff05c98 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts @@ -0,0 +1,97 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { Item } from '../../core/shared/item.model'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-grant-deny-request-copy', + styleUrls: ['./grant-deny-request-copy.component.scss'], + templateUrl: './grant-deny-request-copy.component.html' +}) +/** + * Component for an author to decide to grant or deny an item request + */ +export class GrantDenyRequestCopyComponent implements OnInit { + /** + * The item request to grant or deny + */ + itemRequestRD$: Observable>; + + /** + * The item the request is requesting access to + */ + itemRD$: Observable>; + + /** + * The name of the item + */ + itemName$: Observable; + + /** + * The url of the item + */ + itemUrl$: Observable; + + /** + * The route to the page for denying access to the item + */ + denyRoute$: Observable; + + /** + * The route to the page for granting access to the item + */ + grantRoute$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + this.itemRD$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemDataService.findById(itemRequest.itemId)), + ); + this.itemName$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => this.nameService.getName(item)), + ); + this.itemUrl$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => getItemPageRoute(item)), + ); + + this.denyRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyDenyRoute(itemRequest.token)) + ); + this.grantRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyGrantRoute(itemRequest.token)) + ); + } + +} diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html new file mode 100644 index 0000000000..d2c2cfc3c8 --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html @@ -0,0 +1,17 @@ +
+

{{'grant-request-copy.header' | translate}}

+
+

{{'grant-request-copy.intro' | translate}}

+ + +

{{ 'grant-deny-request-copy.email.permissions.info' | translate }}

+
+
+ + +
+
+
+
+ +
diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss b/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts new file mode 100644 index 0000000000..b6ccb8557e --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { GrantRequestCopyComponent } from './grant-request-copy.component'; + +describe('GrantRequestCopyComponent', () => { + let component: GrantRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + grant: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [GrantRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('grant', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts new file mode 100644 index 0000000000..3b8ec8f735 --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -0,0 +1,118 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-grant-request-copy', + styleUrls: ['./grant-request-copy.component.scss'], + templateUrl: './grant-request-copy.component.html' +}) +/** + * Component for granting an item request + */ +export class GrantRequestCopyComponent implements OnInit { + /** + * The item request to accept + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + /** + * Whether or not the item should be open access, to avoid future requests + * Defaults to false + */ + suggestOpenAccess = false; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('grant-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('grant-request-copy.email.message', params)), + ); + } + + /** + * Grant the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + grant(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('grant-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('grant-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/request-copy-routing-paths.ts b/src/app/request-copy/request-copy-routing-paths.ts new file mode 100644 index 0000000000..1d0204a1b8 --- /dev/null +++ b/src/app/request-copy/request-copy-routing-paths.ts @@ -0,0 +1,18 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getRequestCopyModulePath } from '../app-routing-paths'; + +export function getRequestCopyRoute(token: string) { + return new URLCombiner(getRequestCopyModulePath(), token).toString(); +} + +export const REQUEST_COPY_DENY_PATH = 'deny'; + +export function getRequestCopyDenyRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_DENY_PATH).toString(); +} + +export const REQUEST_COPY_GRANT_PATH = 'grant'; + +export function getRequestCopyGrantRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_GRANT_PATH).toString(); +} diff --git a/src/app/request-copy/request-copy-routing.module.ts b/src/app/request-copy/request-copy-routing.module.ts new file mode 100644 index 0000000000..e7a205d0aa --- /dev/null +++ b/src/app/request-copy/request-copy-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { RequestCopyResolver } from './request-copy.resolver'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { REQUEST_COPY_DENY_PATH, REQUEST_COPY_GRANT_PATH } from './request-copy-routing-paths'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: ':token', + resolve: { + request: RequestCopyResolver + }, + children: [ + { + path: '', + component: GrantDenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_DENY_PATH, + component: DenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_GRANT_PATH, + component: GrantRequestCopyComponent, + }, + ] + } + ]) + ], + providers: [ + RequestCopyResolver, + GrantDenyRequestCopyComponent + ] +}) +export class RequestCopyRoutingModule { +} diff --git a/src/app/request-copy/request-copy.module.ts b/src/app/request-copy/request-copy.module.ts new file mode 100644 index 0000000000..d55d5ad83f --- /dev/null +++ b/src/app/request-copy/request-copy.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { RequestCopyRoutingModule } from './request-copy-routing.module'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { EmailRequestCopyComponent } from './email-request-copy/email-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RequestCopyRoutingModule + ], + declarations: [ + GrantDenyRequestCopyComponent, + DenyRequestCopyComponent, + EmailRequestCopyComponent, + GrantRequestCopyComponent, + ], + providers: [] +}) + +/** + * Module related to components used to grant or deny an item request + */ +export class RequestCopyModule { + +} diff --git a/src/app/request-copy/request-copy.resolver.ts b/src/app/request-copy/request-copy.resolver.ts new file mode 100644 index 0000000000..e5c98b0138 --- /dev/null +++ b/src/app/request-copy/request-copy.resolver.ts @@ -0,0 +1,26 @@ +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { ItemRequest } from '../core/shared/item-request.model'; +import { Observable } from 'rxjs'; +import { ItemRequestDataService } from '../core/data/item-request-data.service'; +import { Injectable } from '@angular/core'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * Resolves an {@link ItemRequest} from the token found in the route's parameters + */ +@Injectable() +export class RequestCopyResolver implements Resolve> { + + constructor( + private itemRequestDataService: ItemRequestDataService, + ) { + } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> | Promise> | RemoteData { + return this.itemRequestDataService.findById(route.params.token).pipe( + getFirstCompletedRemoteData(), + ); + } + +} diff --git a/src/app/search-page/configuration-search-page.component.spec.ts b/src/app/search-page/configuration-search-page.component.spec.ts index 8ce4154c66..5ca593981f 100644 --- a/src/app/search-page/configuration-search-page.component.spec.ts +++ b/src/app/search-page/configuration-search-page.component.spec.ts @@ -55,10 +55,4 @@ describe('ConfigurationSearchPageComponent', () => { expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', QUERY); }); - it('should reset route parameters on destroy', () => { - fixture.destroy(); - - expect(routeService.setParameter).toHaveBeenCalledWith('configuration', undefined); - expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', undefined); - }); }); diff --git a/src/app/search-page/configuration-search-page.component.ts b/src/app/search-page/configuration-search-page.component.ts index 1eefeeb569..df25febde7 100644 --- a/src/app/search-page/configuration-search-page.component.ts +++ b/src/app/search-page/configuration-search-page.component.ts @@ -6,7 +6,6 @@ import { Component, Inject, Input, - OnDestroy, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; @@ -34,7 +33,7 @@ import { Router } from '@angular/router'; ] }) -export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit, OnDestroy { +export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit { /** * The configuration to use for the search options * If empty, the configuration will be determined by the route parameter called 'configuration' @@ -72,17 +71,4 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery); } } - - /** - * Reset the updated query/configuration set in ngOnInit() - */ - ngOnDestroy(): void { - super.ngOnDestroy(); - if (hasValue(this.configuration)) { - this.routeService.setParameter('configuration', undefined); - } - if (hasValue(this.fixedFilterQuery)) { - this.routeService.setParameter('fixedFilterQuery', undefined); - } - } } diff --git a/src/app/search-page/search.component.html b/src/app/search-page/search.component.html index d8aa25e4a3..3489cccdfb 100644 --- a/src/app/search-page/search.component.html +++ b/src/app/search-page/search.component.html @@ -47,12 +47,12 @@ [query]="(searchOptions$ | async)?.query" [scope]="(searchOptions$ | async)?.scope" [currentUrl]="searchLink" - [scopes]="(scopeListRD$ | async)" + [showScopeSelector]="true" [inPlaceSearch]="inPlaceSearch" [searchPlaceholder]="'search.search-form.placeholder' | translate">
-
+
diff --git a/src/app/search-page/search.component.ts b/src/app/search-page/search.component.ts index d4d65b87fe..8be21af552 100644 --- a/src/app/search-page/search.component.ts +++ b/src/app/search-page/search.component.ts @@ -55,11 +55,6 @@ export class SearchComponent implements OnInit { */ sortOptions$: Observable; - /** - * The current relevant scopes - */ - scopeListRD$: Observable; - /** * Emits true if were on a small screen */ @@ -137,9 +132,7 @@ export class SearchComponent implements OnInit { ).subscribe((results) => { this.resultsRD$.next(results); }); - this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( - switchMap((scopeId) => this.service.getScopes(scopeId)) - ); + if (isEmpty(this.configuration$)) { this.configuration$ = this.searchConfigService.getCurrentConfiguration('default'); } diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html new file mode 100644 index 0000000000..1fae737fdb --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html @@ -0,0 +1,87 @@ +
+

{{'bitstream-request-a-copy.header' | translate}}

+
+ {{'bitstream-request-a-copy.alert.canDownload1' | translate}} + {{'bitstream-request-a-copy.alert.canDownload2'| translate}} +
+
+

{{'bitstream-request-a-copy.intro' | translate}} {{itemName}}

+

{{'bitstream-request-a-copy.intro.bitstream.one' | translate}} {{bitstreamName}}

+

{{'bitstream-request-a-copy.intro.bitstream.all' | translate}}

+
+
+ +
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.name.error' | translate }} + +
+
+
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.email.error' | translate }} + +
+ {{'bitstream-request-a-copy.email.hint' |translate}} +
+
+
+
+
{{'bitstream-request-a-copy.allfiles.label' |translate}}
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + {{'bitstream-request-a-copy.return' | translate}} + + + +
+
+
diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts new file mode 100644 index 0000000000..cc44ef8587 --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts @@ -0,0 +1,289 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { AuthService } from '../../core/auth/auth.service'; +import { of as observableOf } from 'rxjs'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; +import { By } from '@angular/platform-browser'; +import { RouterStub } from '../testing/router.stub'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSONameServiceMock } from '../mocks/dso-name.service.mock'; +import { Item } from '../../core/shared/item.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; + + +describe('BitstreamRequestACopyPageComponent', () => { + let component: BitstreamRequestACopyPageComponent; + let fixture: ComponentFixture; + + let authService: AuthService; + let authorizationService: AuthorizationDataService; + let activatedRoute; + let router; + let itemRequestDataService; + let notificationsService; + let location; + let bitstreamDataService; + + let item: Item; + let bitstream: Bitstream; + let eperson; + + function init() { + eperson = Object.assign(new EPerson(), { + email: 'test@mail.org', + metadata: { + 'eperson.firstname': [{value: 'Test'}], + 'eperson.lastname': [{value: 'User'}], + } + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(false), + getAuthenticatedUserFromStore: observableOf(eperson) + }); + authorizationService = jasmine.createSpyObj('authorizationSerivice', { + isAuthorized: observableOf(true) + }); + + itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { + requestACopy: createSuccessfulRemoteDataObject$({}) + }); + + location = jasmine.createSpyObj('location', { + back: {} + }); + + notificationsService = new NotificationsServiceStub(); + + item = Object.assign(new Item(), {uuid: 'item-uuid'}); + + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + content: {href: 'bitstream-content-link'}, + self: {href: 'bitstream-self-link'}, + } + }); + + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + bitstream : bitstream.uuid + }) + }; + + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findById: createSuccessfulRemoteDataObject$(bitstream) + }); + + router = new RouterStub(); + } + + function initTestbed() { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), FormsModule, ReactiveFormsModule], + declarations: [BitstreamRequestACopyPageComponent], + providers: [ + {provide: Location, useValue: location}, + {provide: ActivatedRoute, useValue: activatedRoute}, + {provide: Router, useValue: router}, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: AuthService, useValue: authService}, + {provide: ItemRequestDataService, useValue: itemRequestDataService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: DSONameService, useValue: new DSONameServiceMock()}, + {provide: BitstreamDataService, useValue: bitstreamDataService}, + ] + }) + .compileComponents(); + } + + describe('init', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('should show a form to request a copy', () => { + describe('when the user is not logged in', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with no values filled in based on the user', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + + describe('when the user is logged in', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with values filled in based on the user', () => { + fixture.detectChanges(); + expect(component.name.value).toEqual(eperson.name); + expect(component.email.value).toEqual(eperson.email); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + describe('when no bitstream was provided', () => { + beforeEach(waitForAsync(() => { + init(); + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + }) + }; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should set the all files value to true and disable the false value', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('true'); + expect(component.message.value).toEqual(''); + + const allFilesFalse = fixture.debugElement.query(By.css('#allfiles-false')).nativeElement; + expect(allFilesFalse.getAttribute('disabled')).toBeTruthy(); + + }); + }); + describe('when the user has authorization to download the file', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should show an alert indicating the user can download the file', () => { + const alert = fixture.debugElement.query(By.css('.alert')).nativeElement; + expect(alert.innerHTML).toContain('bitstream-request-a-copy.alert.canDownload'); + }); + }); + }); + + describe('onSubmit', () => { + describe('onSuccess', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.success).toHaveBeenCalled(); + expect(location.back).toHaveBeenCalled(); + }); + }); + + describe('onFail', () => { + beforeEach(waitForAsync(() => { + init(); + (itemRequestDataService.requestACopy as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.error).toHaveBeenCalled(); + expect(location.back).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts new file mode 100644 index 0000000000..511079a701 --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts @@ -0,0 +1,212 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { AuthService } from '../../core/auth/auth.service'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; +import { getBitstreamDownloadRoute, getForbiddenRoute } from '../../app-routing-paths'; +import { TranslateService } from '@ngx-translate/core'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-bitstream-request-a-copy-page', + templateUrl: './bitstream-request-a-copy-page.component.html' +}) +/** + * Page component for requesting a copy for a bitstream + */ +export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { + + item$: Observable; + + canDownload$: Observable; + private subs: Subscription[] = []; + requestCopyForm: FormGroup; + + item: Item; + itemName: string; + + bitstream$: Observable; + bitstream: Bitstream; + bitstreamName: string; + + constructor(private location: Location, + private translateService: TranslateService, + private route: ActivatedRoute, + protected router: Router, + private authorizationService: AuthorizationDataService, + private auth: AuthService, + private formBuilder: FormBuilder, + private itemRequestDataService: ItemRequestDataService, + private notificationsService: NotificationsService, + private dsoNameService: DSONameService, + private bitstreamService: BitstreamDataService, + ) { + } + + ngOnInit(): void { + this.requestCopyForm = this.formBuilder.group({ + name: new FormControl('', { + validators: [Validators.required], + }), + email: new FormControl('', { + validators: [Validators.required, + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$')] + }), + allfiles: new FormControl(''), + message: new FormControl(''), + }); + + + this.item$ = this.route.data.pipe( + map((data) => data.dso), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.item$.subscribe((item) => { + this.item = item; + this.itemName = this.dsoNameService.getName(item); + })); + + this.bitstream$ = this.route.queryParams.pipe( + filter((params) => hasValue(params) && hasValue(params.bitstream)), + switchMap((params) => this.bitstreamService.findById(params.bitstream)), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.bitstream$.subscribe((bitstream) => { + this.bitstream = bitstream; + this.bitstreamName = this.dsoNameService.getName(bitstream); + })); + + this.canDownload$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined)) + ); + const canRequestCopy$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(bitstream) ? bitstream.self : undefined)), + ); + + this.subs.push(observableCombineLatest([this.canDownload$, canRequestCopy$]).subscribe(([canDownload, canRequestCopy]) => { + if (!canDownload && !canRequestCopy) { + this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + } + })); + this.initValues(); + } + + get name() { + return this.requestCopyForm.get('name'); + } + + get email() { + return this.requestCopyForm.get('email'); + } + + get message() { + return this.requestCopyForm.get('message'); + } + + get allfiles() { + return this.requestCopyForm.get('allfiles'); + } + + /** + * Initialise the form values based on the current user. + */ + private initValues() { + this.getCurrentUser().pipe(take(1)).subscribe((user) => { + this.requestCopyForm.patchValue({allfiles: 'true'}); + if (hasValue(user)) { + this.requestCopyForm.patchValue({name: user.name, email: user.email}); + } + }); + this.bitstream$.pipe(take(1)).subscribe((bitstream) => { + this.requestCopyForm.patchValue({allfiles: 'false'}); + }); + } + + /** + * Retrieve the current user + */ + private getCurrentUser(): Observable { + return this.auth.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.auth.getAuthenticatedUserFromStore(); + } else { + return observableOf(undefined); + } + }) + ); + + } + + /** + * Submit the the form values as an item request to the server. + * When the submission is successful, the user will be redirected to the item page and a success notification will be shown. + * When the submission fails, the user will stay on the page and an error notification will be shown + */ + onSubmit() { + const itemRequest = new ItemRequest(); + if (hasValue(this.bitstream)) { + itemRequest.bitstreamId = this.bitstream.uuid; + } + itemRequest.itemId = this.item.uuid; + itemRequest.allfiles = this.allfiles.value; + itemRequest.requestEmail = this.email.value; + itemRequest.requestName = this.name.value; + itemRequest.requestMessage = this.message.value; + + this.itemRequestDataService.requestACopy(itemRequest).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('bitstream-request-a-copy.submit.success')); + this.navigateBack(); + } else { + this.notificationsService.error(this.translateService.get('bitstream-request-a-copy.submit.error')); + } + }); + } + + ngOnDestroy(): void { + if (hasValue(this.subs)) { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } + } + + /** + * Navigates back to the user's previous location + */ + navigateBack() { + this.location.back(); + } + + getItemPath() { + return [getItemPageRoute(this.item)]; + } + + /** + * Retrieves the link to the bistream download page + */ + getBitstreamLink() { + return [getBitstreamDownloadRoute(this.bitstream)]; + } +} diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 31c4feb8bd..cb0c799762 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -41,6 +41,8 @@ describe('BrowseByComponent', () => { let comp: BrowseByComponent; let fixture: ComponentFixture; + let themeService: ThemeService; + const mockItems = [ Object.assign(new Item(), { id: 'fakeId-1', @@ -74,8 +76,10 @@ describe('BrowseByComponent', () => { let themeService: SpyObj; beforeEach(waitForAsync(() => { - themeService = jasmine.createSpyObj('ThemeService', ['getThemeName', 'getThemeName$']); - + themeService = jasmine.createSpyObj('themeService', { + getThemeName: 'dspace', + getThemeName$: observableOf('dspace'), + }); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -96,7 +100,7 @@ describe('BrowseByComponent', () => { providers: [ {provide: PaginationService, useValue: paginationService}, {provide: MockThemedBrowseEntryListElementComponent}, - {provide: ThemeService, useValue: themeService}, + { provide: ThemeService, useValue: themeService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/shared/chips/chips.component.ts b/src/app/shared/chips/chips.component.ts index 1dc7641c25..17a6b034ee 100644 --- a/src/app/shared/chips/chips.component.ts +++ b/src/app/shared/chips/chips.component.ts @@ -8,7 +8,7 @@ import { ChipsItem } from './models/chips-item.model'; import { UploaderService } from '../uploader/uploader.service'; import { TranslateService } from '@ngx-translate/core'; import { Options } from 'sortablejs'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { BehaviorSubject } from 'rxjs'; @Component({ selector: 'ds-chips', diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index 36269294c1..831c70ad11 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.html +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -1,33 +1,31 @@ -
- +
+
-
-
- - + + - -
-
\ No newline at end of file + + + +
diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts index f08df65ca4..2dc1ee433c 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.spec.ts @@ -5,21 +5,16 @@ import { By } from '@angular/platform-browser'; import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; import { CollectionDropdownComponent } from './collection-dropdown.component'; -import { RemoteData } from '../../core/data/remote-data'; -import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { PageInfo } from '../../core/shared/page-info.model'; import { Collection } from '../../core/shared/collection.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; import { Community } from '../../core/shared/community.model'; import { MockElementRef } from '../testing/element-ref.mock'; -import { FollowLinkConfig } from '../utils/follow-link-config.model'; -import { FindListOptions } from '../../core/data/request.models'; -import { Observable } from 'rxjs/internal/Observable'; const community: Community = Object.assign(new Community(), { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', @@ -99,17 +94,6 @@ const listElementMock = { } }; -// tslint:disable-next-line: max-classes-per-file -class CollectionDataServiceMock { - getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return observableOf( - createSuccessfulRemoteDataObject( - buildPaginatedList(new PageInfo(), collections) - ) - ); - } -} - describe('CollectionDropdownComponent', () => { let component: CollectionDropdownComponent; let componentAsAny: any; @@ -117,12 +101,16 @@ describe('CollectionDropdownComponent', () => { let scheduler: TestScheduler; const collectionDataServiceMock: any = jasmine.createSpyObj('CollectionDataService', { - getAuthorizedCollection: jasmine.createSpy('getAuthorizedCollection') + getAuthorizedCollection: jasmine.createSpy('getAuthorizedCollection'), + getAuthorizedCollectionByEntityType: jasmine.createSpy('getAuthorizedCollectionByEntityType') }); const paginatedCollection = buildPaginatedList(new PageInfo(), collections); const paginatedCollectionRD$ = createSuccessfulRemoteDataObject$(paginatedCollection); + const paginatedOneElementCollection = buildPaginatedList(new PageInfo(), [collections[0]]); + const paginatedOneElementCollectionRD$ = createSuccessfulRemoteDataObject$(paginatedOneElementCollection); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -150,6 +138,7 @@ describe('CollectionDropdownComponent', () => { component = fixture.componentInstance; componentAsAny = component; componentAsAny.collectionDataService.getAuthorizedCollection.and.returnValue(paginatedCollectionRD$); + componentAsAny.collectionDataService.getAuthorizedCollectionByEntityType.and.returnValue(paginatedCollectionRD$); }); it('should init component with collection list', () => { @@ -211,10 +200,10 @@ describe('CollectionDropdownComponent', () => { }); it('should change loader status', () => { - spyOn(component.isLoadingList, 'next').and.callThrough(); + spyOn(component.isLoading, 'next').and.callThrough(); component.hideShowLoader(true); - expect(component.isLoadingList.next).toHaveBeenCalledWith(true); + expect(component.isLoading.next).toHaveBeenCalledWith(true); }); it('reset pagination fields', () => { @@ -225,4 +214,36 @@ describe('CollectionDropdownComponent', () => { expect(component.hasNextPage).toEqual(true); expect(component.searchListCollection).toEqual([]); }); + + it('should invoke the method getAuthorizedCollectionByEntityType of CollectionDataService when entityType is set',() => { + component.entityType = 'rel'; + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + expect((component as any).collectionDataService.getAuthorizedCollectionByEntityType).toHaveBeenCalled(); + }); + + it('should emit hasChoice true when totalElements is greater then one', () => { + spyOn(component.searchComplete, 'emit').and.callThrough(); + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.searchComplete.emit).toHaveBeenCalledWith(); + }); + + it('should emit theOnlySelectable when totalElements is equal to one', () => { + + componentAsAny.collectionDataService.getAuthorizedCollection.and.returnValue(paginatedOneElementCollectionRD$); + componentAsAny.collectionDataService.getAuthorizedCollectionByEntityType.and.returnValue(paginatedOneElementCollectionRD$); + + spyOn(component.theOnlySelectable, 'emit').and.callThrough(); + component.ngOnInit(); + fixture.detectChanges(); + + const expectedTheOnlySelectable = { + communities: [ { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'Community 1', uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88' } ], + collection: { id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', name: 'Collection 1' } + }; + + expect(component.theOnlySelectable.emit).toHaveBeenCalledWith(expectedTheOnlySelectable); + }); }); diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index c91ddbdb0a..d7a341d9b6 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -4,14 +4,15 @@ import { ElementRef, EventEmitter, HostListener, + Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, mergeMap, reduce, startWith, switchMap } from 'rxjs/operators'; +import { BehaviorSubject, from as observableFrom, Observable, of as observableOf, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, mergeMap, reduce, startWith, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../empty.util'; import { RemoteData } from '../../core/data/remote-data'; @@ -21,10 +22,7 @@ import { Community } from '../../core/shared/community.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { followLink } from '../utils/follow-link-config.model'; -import { - getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteWithNotEmptyData -} from '../../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; /** * An interface to represent a collection entry @@ -89,10 +87,10 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { /** * A boolean representing if the loader is visible or not */ - isLoadingList: BehaviorSubject = new BehaviorSubject(false); + isLoading: BehaviorSubject = new BehaviorSubject(false); /** - * A numeric representig current page + * A numeric representing current page */ currentPage: number; @@ -102,10 +100,25 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { hasNextPage: boolean; /** - * Current seach query used to filter collection list + * Current search query used to filter collection list */ currentQuery: string; + /** + * If present this value is used to filter collection list by entity type + */ + @Input() entityType: string; + + /** + * Emit to notify whether search is complete + */ + @Output() searchComplete = new EventEmitter(); + + /** + * Emit to notify the only selectable collection. + */ + @Output() theOnlySelectable = new EventEmitter(); + constructor( private changeDetectorRef: ChangeDetectorRef, private collectionDataService: CollectionDataService, @@ -132,6 +145,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * Initialize collection list */ ngOnInit() { + this.isLoading.next(false); this.subs.push(this.searchField.valueChanges.pipe( debounceTime(500), distinctUntilChanged(), @@ -160,7 +174,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { } /** - * Method used from infitity scroll for retrive more data on scroll down + * Method used from infinity scroll for retrieve more data on scroll down */ onScrollDown() { if ( this.hasNextPage ) { @@ -175,6 +189,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * the selected [CollectionListEntry] */ onSelect(event: CollectionListEntry) { + this.isLoading.next(true); this.selectionChange.emit(event); } @@ -184,36 +199,57 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * @param page page number */ populateCollectionList(query: string, page: number) { - this.isLoadingList.next(true); + this.isLoading.next(true); // Set the pagination info const findOptions: FindListOptions = { elementsPerPage: 10, currentPage: page }; - this.searchListCollection$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, true, false, followLink('parentCommunity')) - .pipe( - getFirstSucceededRemoteWithNotEmptyData(), - switchMap((collections: RemoteData>) => { - if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collections.payload.totalElements ) { + let searchListService$: Observable>>; + if (this.entityType) { + searchListService$ = this.collectionDataService + .getAuthorizedCollectionByEntityType( + query, + this.entityType, + findOptions, + true, + followLink('parentCommunity')); + } else { + searchListService$ = this.collectionDataService + .getAuthorizedCollection(query, findOptions, true, true, followLink('parentCommunity')); + } + this.searchListCollection$ = searchListService$.pipe( + getFirstCompletedRemoteData(), + switchMap((collectionsRD: RemoteData>) => { + this.searchComplete.emit(); + if (collectionsRD.hasSucceeded && collectionsRD.payload.totalElements > 0) { + if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collectionsRD.payload.totalElements ) { + this.hasNextPage = false; + this.emitSelectionEvents(collectionsRD); + return observableFrom(collectionsRD.payload.page).pipe( + mergeMap((collection: Collection) => collection.parentCommunity.pipe( + getFirstSucceededRemoteDataPayload(), + map((community: Community) => ({ + communities: [{ id: community.id, name: community.name }], + collection: { id: collection.id, uuid: collection.id, name: collection.name } + }) + ))), + reduce((acc: any, value: any) => [...acc, value], []), + ); + } + } else { this.hasNextPage = false; + return observableOf([]); } - return collections.payload.page; - }), - mergeMap((collection: Collection) => collection.parentCommunity.pipe( - getFirstSucceededRemoteDataPayload(), - map((community: Community) => ({ - communities: [{ id: community.id, name: community.name }], - collection: { id: collection.id, uuid: collection.id, name: collection.name } - }) - ))), - reduce((acc: any, value: any) => [...acc, value], []), - startWith([]) + }) ); - this.subs.push(this.searchListCollection$.subscribe( - (next) => { this.searchListCollection.push(...next); }, undefined, - () => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); } - )); + this.subs.push( + this.searchListCollection$.subscribe((list: CollectionListEntry[]) => { + this.searchListCollection.push(...list); + this.hideShowLoader(false); + this.changeDetectorRef.detectChanges(); + }) + ); } /** @@ -245,6 +281,29 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { * @param hideShow true for show, false otherwise */ hideShowLoader(hideShow: boolean) { - this.isLoadingList.next(hideShow); + this.isLoading.next(hideShow); } + + /** + * Emit events related to the number of selectable collections. + * hasChoice containing whether there are more then one selectable collections. + * theOnlySelectable containing the only collection available. + * @param collections + * @private + */ + private emitSelectionEvents(collections: RemoteData>) { + if (collections.payload.totalElements === 1) { + const collection = collections.payload.page[0]; + collections.payload.page[0].parentCommunity.pipe( + getFirstSucceededRemoteDataPayload(), + take(1) + ).subscribe((community: Community) => { + this.theOnlySelectable.emit({ + communities: [{ id: community.id, name: community.name, uuid: community.id }], + collection: { id: collection.id, uuid: collection.id, name: collection.name } + }); + }); + } + } + } diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts index ebdb5e5181..1879581d23 100644 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable } from 'rxjs'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; @Component({ diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html new file mode 100644 index 0000000000..0e2e35dcb7 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss new file mode 100644 index 0000000000..e8b7d689a3 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts new file mode 100644 index 0000000000..9839507d57 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DsoPageVersionButtonComponent } from './dso-page-version-button.component'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable, of, of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; + +describe('DsoPageVersionButtonComponent', () => { + let component: DsoPageVersionButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let versionHistoryService: VersionHistoryDataService; + + let dso: Item; + let tooltipMsg: Observable; + + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', + ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', 'hasDraftVersion$'] + ); + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' }, + version: { href: 'test-item-version-selflink' }, + }, + }); + tooltipMsg = of('tooltip-msg'); + + TestBed.configureTestingModule({ + declarations: [DsoPageVersionButtonComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationServiceSpy }, + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, + ] + }).compileComponents(); + + authorizationService = TestBed.inject(AuthorizationDataService); + versionHistoryService = TestBed.inject(VersionHistoryDataService); + + versionHistoryServiceSpy.hasDraftVersion$.and.returnValue(observableOf(true)); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageVersionButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.tooltipMsg$ = tooltipMsg; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanCreateVersion, dso.self); + }); + + it('should check if the item has a draft version', () => { + expect(versionHistoryServiceSpy.hasDraftVersion$).toHaveBeenCalledWith(dso._links.version.href); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); + +}); diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts new file mode 100644 index 0000000000..cf07953c75 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts @@ -0,0 +1,77 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable, of } from 'rxjs'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { map, startWith, switchMap } from 'rxjs/operators'; + +@Component({ + selector: 'ds-dso-page-version-button', + templateUrl: './dso-page-version-button.component.html', + styleUrls: ['./dso-page-version-button.component.scss'] +}) +/** + * Display a button linking to the edit page of a DSpaceObject + */ +export class DsoPageVersionButtonComponent implements OnInit { + /** + * The item for which display a button to create a new version + */ + @Input() dso: Item; + + /** + * A message for the tooltip on the button + * Supports i18n keys + */ + @Input() tooltipMsgCreate: string; + + /** + * A message for the tooltip on the button (when is disabled) + * Supports i18n keys + */ + @Input() tooltipMsgHasDraft: string; + + /** + * Emits an event that triggers the creation of the new version + */ + @Output() newVersionEvent = new EventEmitter(); + + /** + * Whether or not the current user is authorized to create a new version of the DSpaceObject + */ + isAuthorized$: Observable; + + disableNewVersionButton$: Observable; + + tooltipMsg$: Observable; + + constructor( + protected authorizationService: AuthorizationDataService, + protected versionHistoryService: VersionHistoryDataService, + ) { + } + + /** + * Creates a new version for the current item + */ + createNewVersion() { + this.newVersionEvent.emit(); + } + + ngOnInit() { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.dso.self); + + this.disableNewVersionButton$ = this.versionHistoryService.hasDraftVersion$(this.dso._links.version.href).pipe( + // button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null + // (hasDraftVersion is null when a version history does not exist) + map((res) => Boolean(res)), + startWith(true), + ); + + this.tooltipMsg$ = this.disableNewVersionButton$.pipe( + switchMap((hasDraftVersion) => of(hasDraftVersion ? this.tooltipMsgHasDraft : this.tooltipMsgCreate)), + ); + } + +} diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts index 55634dbf7f..b46df8ff36 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts @@ -26,7 +26,8 @@ describe('AuthorizedCollectionSelectorComponent', () => { id: 'authorized-collection' }); collectionService = jasmine.createSpyObj('collectionService', { - getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])) + getAuthorizedCollection: createSuccessfulRemoteDataObject$(createPaginatedList([collection])), + getAuthorizedCollectionByEntityType: createSuccessfulRemoteDataObject$(createPaginatedList([collection])) }); notificationsService = jasmine.createSpyObj('notificationsService', ['error']); TestBed.configureTestingModule({ @@ -49,12 +50,27 @@ describe('AuthorizedCollectionSelectorComponent', () => { }); describe('search', () => { - it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { + describe('when has no entity type', () => { + it('should call getAuthorizedCollection and return the authorized collection in a SearchResult', (done) => { component.search('', 1).subscribe((resultRD) => { - expect(collectionService.getAuthorizedCollection).toHaveBeenCalled(); + expect(collectionService.getAuthorizedCollection).toHaveBeenCalled(); expect(resultRD.payload.page.length).toEqual(1); expect(resultRD.payload.page[0].indexableObject).toEqual(collection); - done(); + done(); + }); + }); + }); + + describe('when has entity type', () => { + it('should call getAuthorizedCollectionByEntityType and return the authorized collection in a SearchResult', (done) => { + component.entityType = 'test'; + fixture.detectChanges(); + component.search('', 1).subscribe((resultRD) => { + expect(collectionService.getAuthorizedCollectionByEntityType).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(collection); + done(); + }); }); }); }); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index bca1727542..b6aa0b3413 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -1,8 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { DSOSelectorComponent } from '../dso-selector.component'; import { SearchService } from '../../../../core/shared/search/search.service'; import { CollectionDataService } from '../../../../core/data/collection-data.service'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable } from 'rxjs'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { map } from 'rxjs/operators'; import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; @@ -14,6 +14,8 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { hasValue } from '../../../empty.util'; import { NotificationsService } from '../../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { Collection } from '../../../../core/shared/collection.model'; +import { FindListOptions } from '../../../../core/data/request.models'; @Component({ selector: 'ds-authorized-collection-selector', @@ -24,6 +26,11 @@ import { TranslateService } from '@ngx-translate/core'; * Component rendering a list of collections to select from */ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent { + /** + * If present this value is used to filter collection list by entity type + */ + @Input() entityType: string; + constructor(protected searchService: SearchService, protected collectionDataService: CollectionDataService, protected notifcationsService: NotificationsService, @@ -44,10 +51,23 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent * @param page Page to retrieve */ search(query: string, page: number): Observable>>> { - return this.collectionDataService.getAuthorizedCollection(query, Object.assign({ + let searchListService$: Observable>> = null; + const findOptions: FindListOptions = { currentPage: page, elementsPerPage: this.defaultPagination.pageSize - }),true, false, followLink('parentCommunity')).pipe( + }; + + if (this.entityType) { + searchListService$ = this.collectionDataService + .getAuthorizedCollectionByEntityType( + query, + this.entityType, + findOptions); + } else { + searchListService$ = this.collectionDataService + .getAuthorizedCollection(query, findOptions, true, false, followLink('parentCommunity')); + } + return searchListService$.pipe( getFirstCompletedRemoteData(), map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))) : null, diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html index 8761e4eb9e..5fccd58f48 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -6,6 +6,9 @@
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts index 3afb3c5e9a..90bd07c52b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts @@ -69,4 +69,10 @@ describe('CreateItemParentSelectorComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/submit'], { queryParams: { collection: collection.uuid } }); }); + it('should call navigate on the router with entityType parameter', () => { + const entityType = 'Person'; + component.entityType = entityType; + component.navigate(collection); + expect(router.navigate).toHaveBeenCalledWith(['/submit'], { queryParams: { collection: collection.uuid, entityType: entityType } }); + }); }); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index 03d7732fb0..b109be0af2 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; @@ -22,6 +22,11 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo action = SelectorActionType.CREATE; header = 'dso-selector.create.item.sub-level'; + /** + * If present this value is used to filter collection list by entity type + */ + @Input() entityType: string; + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); } @@ -35,6 +40,9 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo ['collection']: dso.uuid, } }; + if (this.entityType) { + navigationExtras.queryParams.entityType = this.entityType; + } this.router.navigate(['/submit'], navigationExtras); } } diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 8a8f02d72f..ca8343cfad 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -9,7 +9,8 @@ import { hasValue, isNotEmpty } from '../../empty.util'; export enum SelectorActionType { CREATE = 'create', EDIT = 'edit', - EXPORT_METADATA = 'export-metadata' + EXPORT_METADATA = 'export-metadata', + SET_SCOPE = 'set-scope' } /** @@ -77,6 +78,7 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { } } } + /** * Method called when an object has been selected * @param dso The selected DSpaceObject diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts index 1a43e82aef..a04fc0a1cd 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts @@ -1,9 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable, of as observableOf } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; -import { of as observableOf } from 'rxjs'; import { METADATA_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service'; import { Collection } from '../../../../core/shared/collection.model'; import { Community } from '../../../../core/shared/community.model'; diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.html b/src/app/shared/entity-dropdown/entity-dropdown.component.html new file mode 100644 index 0000000000..59c242ef97 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.html @@ -0,0 +1,28 @@ +
+ + + + +
diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.scss b/src/app/shared/entity-dropdown/entity-dropdown.component.scss new file mode 100644 index 0000000000..a5f43f359b --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.scss @@ -0,0 +1,19 @@ +.list-item:active { + color: white !important; +} + +.scrollable-menu { + height: auto; + max-height: var(--ds-dropdown-menu-max-height); + overflow-x: hidden; +} + +.entity-item { + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); +} + +#entityControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: var(--bs-btn-focus-box-shadow); +} diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts new file mode 100644 index 0000000000..0cc14cae22 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts @@ -0,0 +1,167 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { EntityDropdownComponent } from './entity-dropdown.component'; +import { getTestScheduler } from 'jasmine-marbles'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA, Pipe, PipeTransform } from '@angular/core'; +import { EntityTypeService } from '../../core/data/entity-type.service'; +import { TestScheduler } from 'rxjs/testing'; +import { By } from '@angular/platform-browser'; +import { createPaginatedList } from '../testing/utils.test'; + +// tslint:disable-next-line:pipe-prefix +@Pipe({ name: 'translate' }) +class MockTranslatePipe implements PipeTransform { + transform(value: string): string { + return value; + } +} + +const entities: ItemType[] = [ + Object.assign(new ItemType(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + label: 'Entity_1', + uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88' + }), + Object.assign(new ItemType(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + label: 'Entity_2', + uuid: 'UUID-59ee713b-ee53-4220-8c3f-9860dc84fe33' + }), + Object.assign(new ItemType(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + label: 'Entity_3', + uuid: 'UUID-7127-415f-8919-55be34a6e9ed' + }), + Object.assign(new ItemType(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + label: 'Entity_4', + uuid: 'UUID-59da2ff0-9bf4-45bf-88be-e35abd33f304' + }), + Object.assign(new ItemType(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + label: 'Entity_5', + uuid: 'UUID-a5159760-f362-4659-9e81-e3253ad91ede' + }), +]; + +const listElementMock: ItemType = Object.assign( + new ItemType(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + label: 'Entity_1', + uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88' +} +); + +describe('EntityDropdownComponent', () => { + let component: EntityDropdownComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + + const entityTypeServiceMock: any = jasmine.createSpyObj('EntityTypeService', { + getAllAuthorizedRelationshipType: jasmine.createSpy('getAllAuthorizedRelationshipType'), + getAllAuthorizedRelationshipTypeImport: jasmine.createSpy('getAllAuthorizedRelationshipTypeImport') + }); + + + let translatePipeSpy: jasmine.Spy; + + const paginatedEntities = createPaginatedList(entities); + const paginatedEntitiesRD$ = createSuccessfulRemoteDataObject$(paginatedEntities); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [EntityDropdownComponent, MockTranslatePipe], + providers: [ + { provide: EntityTypeService, useValue: entityTypeServiceMock }, + ChangeDetectorRef + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(EntityDropdownComponent); + component = fixture.componentInstance; + componentAsAny = fixture.componentInstance; + componentAsAny.entityTypeService.getAllAuthorizedRelationshipType.and.returnValue(paginatedEntitiesRD$); + componentAsAny.entityTypeService.getAllAuthorizedRelationshipTypeImport.and.returnValue(paginatedEntitiesRD$); + component.isSubmission = true; + + translatePipeSpy = spyOn(MockTranslatePipe.prototype, 'transform'); + }); + + it('should translate entries', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect(translatePipeSpy).toHaveBeenCalledWith('entity_1.listelement.badge'); + }); + + it('should init component with entities list', () => { + spyOn(component.subs, 'push'); + spyOn(component, 'resetPagination'); + spyOn(component, 'populateEntityList').and.callThrough(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + const elements = fixture.debugElement.queryAll(By.css('.entity-item')); + + expect(elements.length).toEqual(5); + expect(component.subs.push).toHaveBeenCalled(); + expect(component.resetPagination).toHaveBeenCalled(); + expect(component.populateEntityList).toHaveBeenCalled(); + expect((component as any).entityTypeService.getAllAuthorizedRelationshipType).toHaveBeenCalled(); + }); + + it('should trigger onSelect method when select a new entity from list', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + spyOn(component, 'onSelect'); + const entityItem = fixture.debugElement.query(By.css('.entity-item:nth-child(2)')); + entityItem.triggerEventHandler('click', null); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should emit selectionChange event when selecting a new entity', () => { + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.ngOnInit(); + component.onSelect(listElementMock as any); + fixture.detectChanges(); + + expect(component.selectionChange.emit).toHaveBeenCalledWith(listElementMock as any); + }); + + it('should change loader status', () => { + spyOn(component.isLoadingList, 'next').and.callThrough(); + component.hideShowLoader(true); + + expect(component.isLoadingList.next).toHaveBeenCalledWith(true); + }); + + it('reset pagination fields', () => { + component.resetPagination(); + + expect(component.currentPage).toEqual(1); + expect(component.hasNextPage).toEqual(true); + expect(component.searchListEntity).toEqual([]); + }); + + it('should invoke the method getAllAuthorizedRelationshipTypeImport of EntityTypeService when isSubmission is false', () => { + component.isSubmission = false; + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect((component as any).entityTypeService.getAllAuthorizedRelationshipTypeImport).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.ts new file mode 100644 index 0000000000..13d50a8b79 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.ts @@ -0,0 +1,207 @@ +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + OnInit, + Output +} from '@angular/core'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { hasValue } from '../empty.util'; +import { reduce, startWith, switchMap } from 'rxjs/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { FindListOptions } from '../../core/data/request.models'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { EntityTypeService } from '../../core/data/entity-type.service'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { getFirstSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; + +@Component({ + selector: 'ds-entity-dropdown', + templateUrl: './entity-dropdown.component.html', + styleUrls: ['./entity-dropdown.component.scss'] +}) +export class EntityDropdownComponent implements OnInit, OnDestroy { + /** + * The entity list obtained from a search + * @type {Observable} + */ + public searchListEntity$: Observable; + + /** + * A boolean representing if dropdown list is scrollable to the bottom + * @type {boolean} + */ + private scrollableBottom = false; + + /** + * A boolean representing if dropdown list is scrollable to the top + * @type {boolean} + */ + private scrollableTop = false; + + /** + * The list of entity to render + */ + public searchListEntity: ItemType[] = []; + + /** + * TRUE if the parent operation is a 'new submission' operation, FALSE otherwise (eg.: is an 'Import metadata from an external source' operation). + */ + @Input() isSubmission: boolean; + + /** + * The entity to output to the parent component + */ + @Output() selectionChange = new EventEmitter(); + + /** + * A boolean representing if the loader is visible or not + */ + public isLoadingList: BehaviorSubject = new BehaviorSubject(false); + + /** + * A numeric representig current page + */ + public currentPage: number; + + /** + * A boolean representing if exist another page to render + */ + public hasNextPage: boolean; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {EntityTypeService} entityTypeService + * @param {ElementRef} el + */ + constructor( + private changeDetectorRef: ChangeDetectorRef, + private entityTypeService: EntityTypeService, + private el: ElementRef + ) { } + + /** + * Method called on mousewheel event, it prevent the page scroll + * when arriving at the top/bottom of dropdown menu + * + * @param event + * mousewheel event + */ + @HostListener('mousewheel', ['$event']) onMousewheel(event) { + if (event.wheelDelta > 0 && this.scrollableTop) { + event.preventDefault(); + } + if (event.wheelDelta < 0 && this.scrollableBottom) { + event.preventDefault(); + } + } + + /** + * Initialize entity list + */ + ngOnInit() { + this.resetPagination(); + this.populateEntityList(this.currentPage); + } + + /** + * Check if dropdown scrollbar is at the top or bottom of the dropdown list + * + * @param event + */ + public onScroll(event) { + this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); + this.scrollableTop = (event.target.scrollTop === 0); + } + + /** + * Method used from infitity scroll for retrive more data on scroll down + */ + public onScrollDown() { + if ( this.hasNextPage ) { + this.populateEntityList(++this.currentPage); + } + } + + /** + * Emit a [selectionChange] event when a new entity is selected from list + * + * @param event + * the selected [ItemType] + */ + public onSelect(event: ItemType) { + this.selectionChange.emit(event); + } + + /** + * Method called for populate the entity list + * @param page page number + */ + public populateEntityList(page: number) { + this.isLoadingList.next(true); + // Set the pagination info + const findOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: page + }; + let searchListEntity$; + if (this.isSubmission) { + searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipType(findOptions); + } else { + searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipTypeImport(findOptions); + } + this.searchListEntity$ = searchListEntity$.pipe( + getFirstSucceededRemoteWithNotEmptyData(), + switchMap((entityType: RemoteData>) => { + if ( (this.searchListEntity.length + findOptions.elementsPerPage) >= entityType.payload.totalElements ) { + this.hasNextPage = false; + } + return entityType.payload.page; + }), + reduce((acc: any, value: any) => [...acc, value], []), + startWith([]) + ); + this.subs.push( + this.searchListEntity$.subscribe( + (next) => { this.searchListEntity.push(...next); }, undefined, + () => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); } + ) + ); + } + + /** + * Reset pagination values + */ + public resetPagination() { + this.currentPage = 1; + this.hasNextPage = true; + this.searchListEntity = []; + } + + /** + * Hide/Show the entity list loader + * @param hideShow true for show, false otherwise + */ + public hideShowLoader(hideShow: boolean) { + this.isLoadingList.next(hideShow); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 497502d586..0155c40b0a 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,4 +1,5 @@ - + + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 6f7f50e585..61e9ecb4aa 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -1,62 +1,145 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FileDownloadLinkComponent } from './file-download-link.component'; -import { AuthService } from '../../core/auth/auth.service'; -import { FileService } from '../../core/shared/file.service'; -import { of as observableOf } from 'rxjs'; import { Bitstream } from '../../core/shared/bitstream.model'; import { By } from '@angular/platform-browser'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getBitstreamModuleRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { Item } from '../../core/shared/item.model'; +import { getItemModuleRoute } from '../../item-page/item-page-routing-paths'; +import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; let fixture: ComponentFixture; - let authService: AuthService; - let fileService: FileService; + let scheduler; + let authorizationService: AuthorizationDataService; + let bitstream: Bitstream; + let item: Item; function init() { - authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: cold('-a', {a: true}) }); - fileService = jasmine.createSpyObj('fileService', ['downloadFile']); bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', + _links: { + self: {href: 'obj-selflink'} + } + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: {href: 'obj-selflink'} + } }); } - beforeEach(waitForAsync(() => { - init(); + function initTestbed() { TestBed.configureTestingModule({ - declarations: [FileDownloadLinkComponent], + declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ - { provide: AuthService, useValue: authService }, - { provide: FileService, useValue: fileService }, + {provide: AuthorizationDataService, useValue: authorizationService}, ] }) .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - fixture.detectChanges(); - }); + } describe('init', () => { - describe('getBitstreamPath', () => { - it('should set the bitstreamPath based on the input bitstream', () => { - expect(component.bitstreamPath).toEqual(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + describe('when the user has download rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + initTestbed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: true})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')); + expect(lock).toBeNull(); + }); + }); + describe('when the user has no download rights but has the right to request a copy', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId, object) => { + if (featureId === FeatureID.CanDownload) { + return cold('-a', {a: false}); + } + return cold('-a', {a: true}); + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.item = item; + component.bitstream = bitstream; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), queryParams: { bitstream: bitstream.uuid } }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); + }); + describe('when the user has no download rights and no request a copy rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(cold('-a', {a: false})); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); }); }); - - it('should init the component', () => { - const link = fixture.debugElement.query(By.css('a')).nativeElement; - expect(link.href).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); - }); - }); }); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index b415e1e701..a79a71b634 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -1,6 +1,12 @@ import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../../core/shared/bitstream.model'; -import { getBitstreamDownloadRoute } from '../../app-routing-paths'; +import { getBitstreamDownloadRoute, getBitstreamRequestACopyRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { map } from 'rxjs/operators'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { Item } from '../../core/shared/item.model'; @Component({ selector: 'ds-file-download-link', @@ -19,6 +25,8 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() bitstream: Bitstream; + @Input() item: Item; + /** * Additional css classes to apply to link */ @@ -29,13 +37,44 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() isBlank = false; - bitstreamPath: string; + @Input() enableRequestACopy = true; + + bitstreamPath$: Observable<{ + routerLink: string, + queryParams: any, + }>; + + canDownload$: Observable; + + constructor( + private authorizationService: AuthorizationDataService, + ) { + } ngOnInit() { - this.bitstreamPath = this.getBitstreamPath(); + if (this.enableRequestACopy) { + this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe( + map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)) + ); + } else { + this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath()); + this.canDownload$ = observableOf(true); + } } - getBitstreamPath() { - return getBitstreamDownloadRoute(this.bitstream); + getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) { + if (!canDownload && canRequestACopy && hasValue(this.item)) { + return getBitstreamRequestACopyRoute(this.item, this.bitstream); + } + return this.getBitstreamDownloadPath(); + } + + getBitstreamDownloadPath() { + return { + routerLink: getBitstreamDownloadRoute(this.bitstream), + queryParams: {} + }; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 2fa3ea1723..fc115e043a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,79 +1,80 @@
- - - -
+ + +
-
- - - -
+
+ +
+ + +
-
- {{ message | translate: model.validators }} -
+
+ {{ message | translate: model.validators }} +
-
-
- -
-
- -
- - - - - - - -
-
- +
+ +
+
+ +
+
+ + + + + + + +
+
+
- - diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html index a5d6d63418..d5421a254f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -1,50 +1,54 @@ -
- +
+
+ + {{model.placeholder}} * + + - - - + + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss index 9eab449eeb..97698b2102 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss @@ -1,3 +1,7 @@ .col-lg-1 { width: auto; } + +legend { + font-size: initial; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts index 87e49956bb..4989dab93a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -69,6 +69,7 @@ describe('DsDatePickerComponent test suite', () => { [bindId]='bindId' [group]='group' [model]='model' + [legend]='legend' (blur)='onBlur($event)' (change)='onValueChange($event)' (focus)='onFocus($event)'>`; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts index 438f78a6a0..78f9935829 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -20,6 +20,7 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicDsDatePickerModel; + @Input() legend: string; @Output() selected = new EventEmitter(); @Output() remove = new EventEmitter(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index fa71e0b6dd..1c053ffc80 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -1,24 +1,30 @@ import { DynamicDateControlModel, - DynamicDateControlModelConfig, + DynamicDatePickerModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; +export interface DynamicDsDateControlModelConfig extends DynamicDatePickerModelConfig { + legend?: string; +} + /** * Dynamic Date Picker Model class */ export class DynamicDsDatePickerModel extends DynamicDateControlModel { @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; malformedDate: boolean; + legend: string; hasLanguages = false; repeatable = false; - constructor(config: DynamicDateControlModelConfig, layout?: DynamicFormControlLayout) { + constructor(config: DynamicDsDateControlModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.malformedDate = false; + this.legend = config.legend; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 0e48b85c78..8a4d502287 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -1,8 +1,13 @@
-
+
-

{{'form.loading' | translate}}

@@ -40,5 +50,3 @@
- - diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html index 339f6f278d..ab4984f2fe 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -1,7 +1,7 @@ @@ -17,6 +17,11 @@ [repeatable]="repeatable" [context]="context" [query]="query" + [relationshipType]="relationshipType" + [isLeft]="isLeft" + [item]="item" + [isEditRelationship]="isEditRelationship" + [toRemove]="toRemove" (selectObject)="select($event)" (deselectObject)="deselect($event)" class="d-block pt-3"> @@ -40,7 +45,7 @@ - + -
+
diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html new file mode 100644 index 0000000000..bf5c15e963 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss new file mode 100644 index 0000000000..0daf4cfa5f --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss @@ -0,0 +1,3 @@ +#create-community-or-separator { + top: 0; +} \ No newline at end of file diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts new file mode 100644 index 0000000000..42d00aaa08 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ScopeSelectorModalComponent } from './scope-selector-modal.component'; +import { Community } from '../../../core/shared/community.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { RouterStub } from '../../testing/router.stub'; + +describe('ScopeSelectorModalComponent', () => { + let component: ScopeSelectorModalComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + + const community = new Community(); + community.uuid = '1234-1234-1234-1234'; + community.metadata = { + 'dc.title': [Object.assign(new MetadataValue(), { + value: 'Community title', + language: undefined + })] + }; + const router = new RouterStub(); + const communityRD = createSuccessfulRemoteDataObject(community); + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ScopeSelectorModalComponent], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { + provide: ActivatedRoute, + useValue: { + root: { + snapshot: { + data: { + dso: communityRD, + }, + }, + } + }, + }, + { + provide: Router, useValue: router + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ScopeSelectorModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + spyOn(component.scopeChange, 'emit'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call navigate on the router with the correct edit path when navigate is called', () => { + component.navigate(community); + expect(component.scopeChange.emit).toHaveBeenCalledWith(community); + }); + +}); diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts new file mode 100644 index 0000000000..86c3010287 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts @@ -0,0 +1,44 @@ +import { Component, EventEmitter, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../../dso-selector/modal-wrappers/dso-selector-modal-wrapper.component'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +/** + * Component to wrap a button - to select the entire repository - + * and a list of parent communities - for scope selection + * inside a modal + * Used to select a scope + */ +@Component({ + selector: 'ds-scope-selector-modal', + styleUrls: ['./scope-selector-modal.component.scss'], + templateUrl: './scope-selector-modal.component.html', +}) +export class ScopeSelectorModalComponent extends DSOSelectorModalWrapperComponent implements OnInit { + objectType = DSpaceObjectType.COMMUNITY; + /** + * The types of DSO that can be selected from this list + */ + selectorTypes = [DSpaceObjectType.COMMUNITY, DSpaceObjectType.COLLECTION]; + + /** + * The type of action to perform + */ + action = SelectorActionType.SET_SCOPE; + + /** + * Emits the selected scope as a DSpaceObject when a user clicks one + */ + scopeChange = new EventEmitter(); + + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) { + super(activeModal, route); + } + + navigate(dso: DSpaceObject) { + /* Handle complex search navigation in underlying component */ + this.scopeChange.emit(dso); + } +} diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 940f3502c3..fe6191cee7 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -1,17 +1,14 @@ -
-
- -
-
-
- - + +
+
+
+ +
+ + -
+
diff --git a/src/app/shared/search-form/search-form.component.scss b/src/app/shared/search-form/search-form.component.scss index 4576be4b28..cf3a354364 100644 --- a/src/app/shared/search-form/search-form.component.scss +++ b/src/app/shared/search-form/search-form.component.scss @@ -3,3 +3,7 @@ background-color: var(--bs-input-bg); color: var(--bs-input-color); } + +.scope-button { + max-width: var(--ds-search-form-scope-max-width); +} diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 1469eac566..333e48336d 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -8,13 +8,11 @@ import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { SearchService } from '../../core/shared/search/search.service'; -import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; -import { of as observableOf } from 'rxjs'; import { PaginationService } from '../../core/pagination/pagination.service'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { PaginationServiceStub } from '../testing/pagination-service.stub'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -35,7 +33,8 @@ describe('SearchFormComponent', () => { useValue: {} }, { provide: PaginationService, useValue: paginationService }, - { provide: SearchConfigurationService, useValue: searchConfigService } + { provide: SearchConfigurationService, useValue: searchConfigService }, + { provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} } ], declarations: [SearchFormComponent] }).compileComponents(); @@ -48,24 +47,6 @@ describe('SearchFormComponent', () => { el = de.nativeElement; }); - it('should display scopes when available with default and all scopes', () => { - - comp.scopes = objects; - fixture.detectChanges(); - const select: HTMLElement = de.query(By.css('select')).nativeElement; - expect(select).toBeDefined(); - const options: HTMLCollection = select.children; - const defOption: Element = options.item(0); - expect(defOption.getAttribute('value')).toBe(''); - - let index = 1; - objects.forEach((object) => { - expect(options.item(index).textContent).toBe(object.name); - expect(options.item(index).getAttribute('value')).toBe(object.uuid); - index++; - }); - }); - it('should not display scopes when empty', () => { fixture.detectChanges(); const select = de.query(By.css('select')); @@ -84,17 +65,17 @@ describe('SearchFormComponent', () => { })); it('should select correct scope option in scope select', fakeAsync(() => { - comp.scopes = objects; - fixture.detectChanges(); + fixture.detectChanges(); + comp.showScopeSelector = true; const testCommunity = objects[1]; - comp.scope = testCommunity.id; + comp.selectedScope.next(testCommunity); fixture.detectChanges(); tick(); - const scopeSelect = de.query(By.css('select')).nativeElement; + const scopeSelect = de.query(By.css('.scope-button')).nativeElement; - expect(scopeSelect.value).toBe(testCommunity.id); + expect(scopeSelect.textContent).toBe(testCommunity.name); })); // it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => { // comp.query = 'Test String' @@ -118,7 +99,7 @@ describe('SearchFormComponent', () => { // // expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query }); // })); -}); + }); export const objects: DSpaceObject[] = [ Object.assign(new Community(), { diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 2791aee378..cb9b43dbd1 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Router } from '@angular/router'; import { isNotEmpty } from '../empty.util'; @@ -6,6 +6,12 @@ import { SearchService } from '../../core/shared/search/search.service'; import { currentPath } from '../utils/route.utils'; import { PaginationService } from '../../core/pagination/pagination.service'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ScopeSelectorModalComponent } from './scope-selector-modal/scope-selector-modal.component'; +import { take } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; /** * This component renders a simple item page. @@ -22,7 +28,7 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf /** * Component that represents the search form */ -export class SearchFormComponent { +export class SearchFormComponent implements OnInit { /** * The search query */ @@ -39,12 +45,9 @@ export class SearchFormComponent { @Input() scope = ''; - @Input() currentUrl: string; + selectedScope: BehaviorSubject = new BehaviorSubject(undefined); - /** - * The available scopes - */ - @Input() scopes: DSpaceObject[]; + @Input() currentUrl: string; /** * Whether or not the search button should be displayed large @@ -61,15 +64,33 @@ export class SearchFormComponent { */ @Input() searchPlaceholder: string; + /** + * Defines whether or not to show the scope selector + */ + @Input() showScopeSelector = false; + /** * Output the search data on submit */ @Output() submitSearch = new EventEmitter(); - constructor(private router: Router, private searchService: SearchService, + constructor(private router: Router, + private searchService: SearchService, private paginationService: PaginationService, - private searchConfig: SearchConfigurationService - ) { + private searchConfig: SearchConfigurationService, + private modalService: NgbModal, + private dsoService: DSpaceObjectDataService + ) { + } + + /** + * Retrieve the scope object from the URL so we can show its name + */ + ngOnInit(): void { + if (isNotEmpty(this.scope)) { + this.dsoService.findById(this.scope).pipe(getFirstSucceededRemoteDataPayload()) + .subscribe((scope: DSpaceObject) => this.selectedScope.next(scope)); + } } /** @@ -85,8 +106,8 @@ export class SearchFormComponent { * Updates the search when the current scope has been changed * @param {string} scope The new scope */ - onScopeChange(scope: string) { - this.updateSearch({ scope }); + onScopeChange(scope: DSpaceObject) { + this.updateSearch({ scope: scope ? scope.uuid : undefined }); } /** @@ -94,11 +115,11 @@ export class SearchFormComponent { * @param data Updated parameters */ updateSearch(data: any) { - const queryParams = Object.assign({}, data); - const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID); - queryParams[pageParam] = 1; + const queryParams = Object.assign({}, data); + const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID); + queryParams[pageParam] = 1; - this.router.navigate(this.getSearchLinkParts(), { + this.router.navigate(this.getSearchLinkParts(), { queryParams: queryParams, queryParamsHandling: 'merge' }); @@ -131,4 +152,15 @@ export class SearchFormComponent { } return this.getSearchLink().split('/'); } + + /** + * Open the scope modal so the user can select DSO as scope + */ + openScopeModal() { + const ref = this.modalService.open(ScopeSelectorModalComponent); + ref.componentInstance.scopeChange.pipe(take(1)).subscribe((scope: DSpaceObject) => { + this.selectedScope.next(scope); + this.onScopeChange(scope); + }); + } } diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index 6e7d9c76bf..4e6bca094e 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -7,6 +7,7 @@ [hideGear]="true" [selectable]="selectable" [selectionConfig]="selectionConfig" + [linkType]="linkType" [context]="context" [hidePaginationDetail]="hidePaginationDetail" (deselectObject)="deselectObject.emit($event)" diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9b993e551f..abc42cb08a 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -53,7 +53,8 @@ import { FormComponent } from './form/form.component'; import { DsDynamicOneboxComponent } from './form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { - DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn, + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn, } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DragClickDirective } from './utils/drag-click.directive'; @@ -186,6 +187,7 @@ import { MissingTranslationHelper } from './translate/missing-translation.helper import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; import { FileValidator } from './utils/require-file.validator'; import { FileValueAccessorDirective } from './utils/file-value-accessor.directive'; +import { FileSectionComponent } from '../item-page/simple/field-components/file-section/file-section.component'; import { ExistingRelationListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component'; import { ModifyItemOverviewComponent } from '../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; @@ -202,6 +204,7 @@ import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-grou import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component'; +import { EntityDropdownComponent } from './entity-dropdown/entity-dropdown.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; import { CurationFormComponent } from '../curation-form/curation-form.component'; @@ -211,6 +214,7 @@ import { CollectionSidebarSearchListElementComponent } from './object-list/sideb import { CommunitySidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component'; import { AuthorizedCollectionSelectorComponent } from './dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DsoPageEditButtonComponent } from './dso-page/dso-page-edit-button/dso-page-edit-button.component'; +import { DsoPageVersionButtonComponent } from './dso-page/dso-page-version-button/dso-page-version-button.component'; import { HoverClassDirective } from './hover-class.directive'; import { ValidationSuggestionsComponent } from './input-suggestions/validation-suggestions/validation-suggestions.component'; import { ItemAlertsComponent } from './item/item-alerts/item-alerts.component'; @@ -233,6 +237,10 @@ import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.com import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component'; import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component'; import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'; +import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; +import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; /** * Declaration needed to make sure all decorator functions are called in time @@ -320,6 +328,7 @@ const COMPONENTS = [ DsDatePickerInlineComponent, DsSelectComponent, ErrorComponent, + FileSectionComponent, FormComponent, LangSwitchComponent, LoadingComponent, @@ -431,7 +440,9 @@ const COMPONENTS = [ GroupSearchBoxComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CollectionDropdownComponent, + EntityDropdownComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, VocabularyTreeviewComponent, @@ -458,7 +469,8 @@ const COMPONENTS = [ PublicationSidebarSearchListElementComponent, CollectionSidebarSearchListElementComponent, CommunitySidebarSearchListElementComponent, - SearchNavbarComponent + SearchNavbarComponent, + ScopeSelectorModalComponent, ]; const ENTRY_COMPONENTS = [ @@ -512,6 +524,7 @@ const ENTRY_COMPONENTS = [ CollectionDropdownComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CurationFormComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, @@ -522,7 +535,8 @@ const ENTRY_COMPONENTS = [ CommunitySidebarSearchListElementComponent, LinkMenuItemComponent, OnClickMenuItemComponent, - TextMenuItemComponent + TextMenuItemComponent, + ScopeSelectorModalComponent, ]; const SHARED_SEARCH_PAGE_COMPONENTS = [ @@ -534,6 +548,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ MetadataFieldWrapperComponent, MetadataValuesComponent, DsoPageEditButtonComponent, + DsoPageVersionButtonComponent, ItemAlertsComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, @@ -584,7 +599,9 @@ const DIRECTIVES = [ ...COMPONENTS, ...DIRECTIVES, ...SHARED_ITEM_PAGE_COMPONENTS, - ...SHARED_SEARCH_PAGE_COMPONENTS + ...SHARED_SEARCH_PAGE_COMPONENTS, + ItemVersionsSummaryModalComponent, + ItemVersionsDeleteModalComponent, ], providers: [ ...PROVIDERS diff --git a/src/app/shared/testing/dynamic-form-mock-services.ts b/src/app/shared/testing/dynamic-form-mock-services.ts index 2cf705ff90..1cbd470e23 100644 --- a/src/app/shared/testing/dynamic-form-mock-services.ts +++ b/src/app/shared/testing/dynamic-form-mock-services.ts @@ -1,5 +1,6 @@ export const mockDynamicFormLayoutService = jasmine.createSpyObj('DynamicFormLayoutService', { - getElementId: jasmine.createSpy('getElementId') + getElementId: jasmine.createSpy('getElementId'), + getClass: 'class', }); export const mockDynamicFormValidationService = jasmine.createSpyObj('DynamicFormValidationService', { diff --git a/src/app/shared/testing/eperson.mock.ts b/src/app/shared/testing/eperson.mock.ts index 0ab3fa9ca1..370c2ff1b9 100644 --- a/src/app/shared/testing/eperson.mock.ts +++ b/src/app/shared/testing/eperson.mock.ts @@ -11,9 +11,9 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(), { selfRegistered: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid', + href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid', }, - groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid/groups' } + groups: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid/groups' } }, id: 'testid', uuid: 'testid', @@ -57,9 +57,9 @@ export const EPersonMock2: EPerson = Object.assign(new EPerson(), { selfRegistered: true, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2', + href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid2', }, - groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2/groups' } + groups: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid2/groups' } }, id: 'testid2', uuid: 'testid2', diff --git a/src/app/shared/testing/group-mock.ts b/src/app/shared/testing/group-mock.ts index 24a78a58e5..a6db4c922e 100644 --- a/src/app/shared/testing/group-mock.ts +++ b/src/app/shared/testing/group-mock.ts @@ -1,5 +1,7 @@ import { Group } from '../../core/eperson/models/group.model'; import { EPersonMock } from './eperson.mock'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; export const GroupMock2: Group = Object.assign(new Group(), { handle: null, @@ -9,16 +11,17 @@ export const GroupMock2: Group = Object.assign(new Group(), { selfRegistered: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2', + href: 'https://rest.api/server/api/eperson/groups/testgroupid2', }, - subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/subgroups' }, - object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' }, - epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons' } + subgroups: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/epersons' } }, _name: 'testgroupname2', id: 'testgroupid2', uuid: 'testgroupid2', type: 'group', + object: createSuccessfulRemoteDataObject$({ name: 'testgroupid2objectName'}) }); export const GroupMock: Group = Object.assign(new Group(), { @@ -29,11 +32,11 @@ export const GroupMock: Group = Object.assign(new Group(), { permanent: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid', + href: 'https://rest.api/server/api/eperson/groups/testgroupid', }, - subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/subgroups' }, - object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' }, - epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons' } + subgroups: { href: 'https://rest.api/server/api/eperson/groups/testgroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/testgroupid/epersons' } }, _name: 'testgroupname', id: 'testgroupid', diff --git a/src/app/shared/testing/related-relationships.mock.ts b/src/app/shared/testing/related-relationships.mock.ts new file mode 100644 index 0000000000..7ea90d5935 --- /dev/null +++ b/src/app/shared/testing/related-relationships.mock.ts @@ -0,0 +1,72 @@ +export const relatedRelationships = { + 'type': { + 'value': 'paginated-list' + }, + 'pageInfo': { + 'elementsPerPage': 5, + 'totalElements': 2, + 'totalPages': 1, + 'currentPage': 1 + }, + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationships/search/byItemsAndType?typeId=1&focusItem=b1b2c768-bda1-448a-a073-fc541e8b24d9&relationshipLabel=isPublicationOfAuthor&size=5&relatedItem=72635f7f-37b5-4875-b4f2-5ff45d97a09b&relatedItem=674f695e-8001-4150-8f9c-095c536a6bcb&relatedItem=a64719f8-ba7b-41d1-8eb6-f8feb0c000b7&relatedItem=75c0f7f5-5a69-40e8-aa1f-8f35b1ce5a63&relatedItem=10bc6f8b-0796-486f-94d8-4d2e1814586f' + }, + 'page': [ + { + 'href': 'http://localhost:8080/server/api/core/relationships/1408' + }, + { + 'href': 'http://localhost:8080/server/api/core/relationships/1409' + } + ] + }, + 'page': [ + { + 'type': 'relationship', + 'uuid': 'relationship-1408', + 'id': 1408, + 'leftPlace': 0, + 'rightPlace': 0, + 'leftwardValue': null, + 'rightwardValue': null, + '_links': { + 'relationshipType': { + 'href': 'http://localhost:8080/server/api/core/relationshiptypes/1' + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationships/1408' + }, + 'leftItem': { + 'href': 'http://localhost:8080/server/api/core/items/75c0f7f5-5a69-40e8-aa1f-8f35b1ce5a63' + }, + 'rightItem': { + 'href': 'http://localhost:8080/server/api/core/items/b1b2c768-bda1-448a-a073-fc541e8b24d9' + } + } + }, + { + 'type': 'relationship', + 'uuid': 'relationship-1409', + 'id': 1409, + 'leftPlace': 0, + 'rightPlace': 1, + 'leftwardValue': null, + 'rightwardValue': null, + '_links': { + 'relationshipType': { + 'href': 'http://localhost:8080/server/api/core/relationshiptypes/1' + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationships/1409' + }, + 'leftItem': { + 'href': 'http://localhost:8080/server/api/core/items/10bc6f8b-0796-486f-94d8-4d2e1814586f' + }, + 'rightItem': { + 'href': 'http://localhost:8080/server/api/core/items/b1b2c768-bda1-448a-a073-fc541e8b24d9' + } + } + } + ] +}; diff --git a/src/app/shared/testing/relationship-types.mock.ts b/src/app/shared/testing/relationship-types.mock.ts new file mode 100644 index 0000000000..2c1f210820 --- /dev/null +++ b/src/app/shared/testing/relationship-types.mock.ts @@ -0,0 +1,71 @@ +export const relationshipTypes = [ + { + 'id': 1, + 'leftwardType': 'isAuthorOfPublication', + 'rightwardType': 'isPublicationOfAuthor', + 'copyToLeft': false, + 'copyToRight': false, + 'leftMinCardinality': 0, + 'leftMaxCardinality': null, + 'rightMinCardinality': 0, + 'rightMaxCardinality': null, + 'type': 'relationshiptype', + '_links': { + 'leftType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/1' + }, + 'rightType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/2' + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationshiptypes/1' + } + } + }, + { + 'id': 4, + 'leftwardType': 'isProjectOfPerson', + 'rightwardType': 'isPersonOfProject', + 'copyToLeft': false, + 'copyToRight': false, + 'leftMinCardinality': 0, + 'leftMaxCardinality': null, + 'rightMinCardinality': 0, + 'rightMaxCardinality': null, + 'type': 'relationshiptype', + '_links': { + 'leftType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/2' + }, + 'rightType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/3' + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationshiptypes/4' + } + } + }, + { + 'id': 5, + 'leftwardType': 'isOrgUnitOfPerson', + 'rightwardType': 'isPersonOfOrgUnit', + 'copyToLeft': false, + 'copyToRight': false, + 'leftMinCardinality': 0, + 'leftMaxCardinality': null, + 'rightMinCardinality': 0, + 'rightMaxCardinality': null, + 'type': 'relationshiptype', + '_links': { + 'leftType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/2' + }, + 'rightType': { + 'href': 'http://localhost:8080/server/api/core/entitytypes/4' + }, + 'self': { + 'href': 'http://localhost:8080/server/api/core/relationshiptypes/5' + } + } + } +]; diff --git a/src/app/shared/testing/utils.test.ts b/src/app/shared/testing/utils.test.ts index 342c987bc2..2f0f07c68a 100644 --- a/src/app/shared/testing/utils.test.ts +++ b/src/app/shared/testing/utils.test.ts @@ -1,9 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; import { PageInfo } from '../../core/shared/page-info.model'; -import { Observable } from 'rxjs/internal/Observable'; +import { Observable, of as observableOf } from 'rxjs'; import { RequestEntry, RequestEntryState } from '../../core/data/request.reducer'; -import { of as observableOf } from 'rxjs/internal/observable/of'; import { UnCacheableObject } from '../../core/shared/uncacheable-object.model'; /** diff --git a/src/app/shared/theme-support/theme.effects.spec.ts b/src/app/shared/theme-support/theme.effects.spec.ts index 7a0e9c8f19..43727df8d6 100644 --- a/src/app/shared/theme-support/theme.effects.spec.ts +++ b/src/app/shared/theme-support/theme.effects.spec.ts @@ -1,75 +1,17 @@ import { ThemeEffects } from './theme.effects'; -import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { LinkService } from '../../core/cache/builders/link.service'; import { cold, hot } from 'jasmine-marbles'; import { ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { SetThemeAction } from './theme.actions'; -import { Theme } from '../../../config/theme.model'; import { provideMockStore } from '@ngrx/store/testing'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; -import { ResolverActionTypes } from '../../core/resolving/resolver.actions'; -import { Community } from '../../core/shared/community.model'; -import { COMMUNITY } from '../../core/shared/community.resource-type'; -import { NoOpAction } from '../ngrx/no-op.action'; -import { ITEM } from '../../core/shared/item.resource-type'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { Item } from '../../core/shared/item.model'; -import { Collection } from '../../core/shared/collection.model'; -import { COLLECTION } from '../../core/shared/collection.resource-type'; -import { - createNoContentRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../remote-data.utils'; import { BASE_THEME_NAME } from './theme.constants'; -/** - * LinkService able to mock recursively resolving DSO parent links - * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until - * none are left, after which it returns a no-content remote-date - */ -class MockLinkService { - index = -1; - - constructor(private ancestorDSOs: DSpaceObject[]) { - } - - resolveLinkWithoutAttaching() { - if (this.index >= this.ancestorDSOs.length - 1) { - return createNoContentRemoteDataObject$(); - } else { - this.index++; - return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); - } - } -} - describe('ThemeEffects', () => { let themeEffects: ThemeEffects; - let linkService: LinkService; let initialState; - let ancestorDSOs: DSpaceObject[]; - function init() { - ancestorDSOs = [ - Object.assign(new Collection(), { - type: COLLECTION.value, - uuid: 'collection-uuid', - _links: { owningCommunity: { href: 'owning-community-link' } } - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'sub-community-uuid', - _links: { parentCommunity: { href: 'parent-community-link' } } - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'top-community-uuid', - }), - ]; - linkService = new MockLinkService(ancestorDSOs) as any; initialState = { theme: { currentTheme: 'custom', @@ -82,7 +24,6 @@ describe('ThemeEffects', () => { TestBed.configureTestingModule({ providers: [ ThemeEffects, - { provide: LinkService, useValue: linkService }, provideMockStore({ initialState }), provideMockActions(() => mockActions) ] @@ -110,205 +51,4 @@ describe('ThemeEffects', () => { expect(themeEffects.initTheme$).toBeObservable(expected); }); }); - - describe('updateThemeOnRouteChange$', () => { - const url = '/test/route'; - const dso = Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', - }); - - function spyOnPrivateMethods() { - spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); - spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); - spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); - } - - describe('when a resolved action is present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--ab-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - b: { - type: ResolverActionTypes.RESOLVED, - payload: { url, dso }, - } - }) - ); - spyOnPrivateMethods(); - }); - - it('should set the theme it receives from the DSO', () => { - const expected = cold('--b-', { - b: new SetThemeAction('custom') - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - - describe('when no resolved action is present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--a-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - }) - ); - spyOnPrivateMethods(); - }); - - it('should set the theme it receives from the route url', () => { - const expected = cold('--b-', { - b: new SetThemeAction('custom') - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - - describe('when no themes are present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--a-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - }) - ); - (themeEffects as any).themes = []; - }); - - it('should return an empty action', () => { - const expected = cold('--b-', { - b: new NoOpAction() - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - }); - - describe('private functions', () => { - beforeEach(() => { - setupEffectsWithActions(hot('-', {})); - }); - - describe('getActionForMatch', () => { - it('should return a SET action if the new theme differs from the current theme', () => { - const theme = new Theme({ name: 'new-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); - }); - - it('should return an empty action if the new theme equals the current theme', () => { - const theme = new Theme({ name: 'old-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); - }); - }); - - describe('matchThemeToDSOs', () => { - let themes: Theme[]; - let nonMatchingTheme: Theme; - let itemMatchingTheme: Theme; - let communityMatchingTheme: Theme; - let dsos: DSpaceObject[]; - - beforeEach(() => { - nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { - matches: () => false - }); - itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { - matches: (url, dso) => (dso as any).type === ITEM.value - }); - communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { - matches: (url, dso) => (dso as any).type === COMMUNITY.value - }); - dsos = [ - Object.assign(new Item(), { - type: ITEM.value, - uuid: 'item-uuid', - }), - Object.assign(new Collection(), { - type: COLLECTION.value, - uuid: 'collection-uuid', - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'community-uuid', - }), - ]; - }); - - describe('when no themes match any of the DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme ]; - themeEffects.themes = themes; - }); - - it('should return undefined', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); - }); - }); - - describe('when one of the themes match a DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - }); - - it('should return the matching theme', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - }); - }); - - describe('when multiple themes match some of the DSOs', () => { - it('should return the first matching theme', () => { - themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - - themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); - }); - }); - }); - - describe('getAncestorDSOs', () => { - it('should return an array of the provided DSO and its ancestors', (done) => { - const dso = Object.assign(new Item(), { - type: ITEM.value, - uuid: 'item-uuid', - _links: { owningCollection: { href: 'owning-collection-link' } }, - }); - - observableOf(dso).pipe( - (themeEffects as any).getAncestorDSOs() - ).subscribe((result) => { - expect(result).toEqual([dso, ...ancestorDSOs]); - done(); - }); - }); - - it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { - const dso = { - type: ITEM.value, - uuid: 'item-uuid', - }; - - observableOf(dso).pipe( - (themeEffects as any).getAncestorDSOs() - ).subscribe((result) => { - expect(result).toEqual([dso]); - done(); - }); - }); - }); - }); }); diff --git a/src/app/shared/theme-support/theme.effects.ts b/src/app/shared/theme-support/theme.effects.ts index 894cfeca75..e120257728 100644 --- a/src/app/shared/theme-support/theme.effects.ts +++ b/src/app/shared/theme-support/theme.effects.ts @@ -1,22 +1,9 @@ import { Injectable } from '@angular/core'; import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; -import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store'; -import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { SetThemeAction } from './theme.actions'; import { environment } from '../../../environments/environment'; -import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model'; -import { hasValue, isNotEmpty, hasNoValue } from '../empty.util'; -import { NoOpAction } from '../ngrx/no-op.action'; -import { Store, select } from '@ngrx/store'; -import { ThemeState } from './theme.reducer'; -import { currentThemeSelector } from './theme.service'; -import { of as observableOf, EMPTY, Observable } from 'rxjs'; -import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions'; -import { followLink } from '../utils/follow-link-config.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { LinkService } from '../../core/cache/builders/link.service'; +import { hasValue, hasNoValue } from '../empty.util'; import { BASE_THEME_NAME } from './theme.constants'; export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) => @@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) = @Injectable() export class ThemeEffects { - /** - * The list of configured themes - */ - themes: Theme[]; - - /** - * True if at least one theme depends on the route - */ - hasDynamicTheme: boolean; - /** * Initialize with a theme that doesn't depend on the route. */ @@ -53,133 +30,8 @@ export class ThemeEffects { ) ); - /** - * An effect that fires when a route change completes, - * and determines whether or not the theme should change - */ - updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe( - // Listen for when a route change ends - ofType(ROUTER_NAVIGATED), - withLatestFrom( - // Pull in the latest resolved action, or undefined if none was dispatched yet - this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)), - // and the current theme from the store - this.store.pipe(select(currentThemeSelector)) - ), - switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => { - if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { - const currentRouteUrl = navigatedAction.payload.routerState.url; - // If resolvedAction exists, and deals with the current url - if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) { - // Start with the resolved dso and go recursively through its parents until you reach the top-level community - return observableOf(resolvedAction.payload.dso).pipe( - this.getAncestorDSOs(), - map((dsos: DSpaceObject[]) => { - const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); - return this.getActionForMatch(dsoMatch, currentTheme); - }) - ); - } - - // check whether the route itself matches - const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); - - return [this.getActionForMatch(routeMatch, currentTheme)]; - } - - // If there are no themes configured, do nothing - return [new NoOpAction()]; - }) - ) - ); - - /** - * return the action to dispatch based on the given matching theme - * - * @param newTheme The theme to create an action for - * @param currentThemeName The name of the currently active theme - * @private - */ - private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction { - if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) { - // If we have a match, and it isn't already the active theme, set it as the new theme - return new SetThemeAction(newTheme.config.name); - } else { - // Otherwise, do nothing - return new NoOpAction(); - } - } - - /** - * Check the given DSpaceObjects in order to see if they match the configured themes in order. - * If a match is found, the matching theme is returned - * - * @param dsos The DSpaceObjects to check - * @param currentRouteUrl The url for the current route - * @private - */ - private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { - // iterate over the themes in order, and return the first one that matches - return this.themes.find((theme: Theme) => { - // iterate over the dsos's in order (most specific one first, so Item, Collection, - // Community), and return the first one that matches the current theme - const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); - return hasValue(match); - }); - - } - - /** - * An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as - * input. The initial DSpaceObject will be the first element of the output array, followed by - * its parent, its grandparent etc - * - * @private - */ - private getAncestorDSOs() { - return (source: Observable): Observable => - source.pipe( - expand((dso: DSpaceObject) => { - // Check if the dso exists and has a parent link - if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') { - const linkName = (dso as any).getParentLinkKey(); - // If it does, retrieve it. - return this.linkService.resolveLinkWithoutAttaching(dso, followLink(linkName)).pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (hasValue(rd.payload)) { - // If there's a parent, use it for the next iteration - return rd.payload; - } else { - // If there's no parent, or an error, return null, which will stop recursion - // in the next iteration - return null; - } - }), - ); - } - - // The current dso has no value, or no parent. Return EMPTY to stop recursion - return EMPTY; - }), - // only allow through DSOs that have a value - filter((dso: DSpaceObject) => hasValue(dso)), - // Wait for recursion to complete, and emit all results at once, in an array - toArray() - ); - } - constructor( private actions$: Actions, - private store: Store, - private linkService: LinkService, ) { - // Create objects from the theme configs in the environment file - this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); - this.hasDynamicTheme = environment.themes.some((themeConfig: any) => - hasValue(themeConfig.regex) || - hasValue(themeConfig.handle) || - hasValue(themeConfig.uuid) - ); } } diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts new file mode 100644 index 0000000000..84043369c0 --- /dev/null +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -0,0 +1,370 @@ +import { of as observableOf } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { cold, hot } from 'jasmine-marbles'; +import { SetThemeAction } from './theme.actions'; +import { Theme } from '../../../config/theme.model'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Community } from '../../core/shared/community.model'; +import { COMMUNITY } from '../../core/shared/community.resource-type'; +import { NoOpAction } from '../ngrx/no-op.action'; +import { ITEM } from '../../core/shared/item.resource-type'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { Collection } from '../../core/shared/collection.model'; +import { COLLECTION } from '../../core/shared/collection.resource-type'; +import { + createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { ThemeService } from './theme.service'; +import { ROUTER_NAVIGATED } from '@ngrx/router-store'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +/** + * LinkService able to mock recursively resolving DSO parent links + * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until + * none are left, after which it returns a no-content remote-date + */ +class MockLinkService { + index = -1; + + constructor(private ancestorDSOs: DSpaceObject[]) { + } + + resolveLinkWithoutAttaching() { + if (this.index >= this.ancestorDSOs.length - 1) { + return createNoContentRemoteDataObject$(); + } else { + this.index++; + return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); + } + } +} + +describe('ThemeService', () => { + let themeService: ThemeService; + let linkService: LinkService; + let initialState; + + let ancestorDSOs: DSpaceObject[]; + + const mockCommunity = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'top-community-uuid', + }); + + function init() { + ancestorDSOs = [ + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + _links: { owningCommunity: { href: 'owning-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'sub-community-uuid', + _links: { parentCommunity: { href: 'parent-community-link' } } + }), + mockCommunity, + ]; + linkService = new MockLinkService(ancestorDSOs) as any; + initialState = { + theme: { + currentTheme: 'custom', + }, + }; + } + + function setupServiceWithActions(mockActions) { + init(); + const mockDsoService = { + findById: () => createSuccessfulRemoteDataObject$(mockCommunity) + }; + TestBed.configureTestingModule({ + providers: [ + ThemeService, + { provide: LinkService, useValue: linkService }, + provideMockStore({ initialState }), + provideMockActions(() => mockActions), + { provide: DSpaceObjectDataService, useValue: mockDsoService } + ] + }); + + themeService = TestBed.inject(ThemeService); + spyOn((themeService as any).store, 'dispatch').and.stub(); + } + + describe('updateThemeOnRouteChange$', () => { + const url = '/test/route'; + const dso = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', + }); + + function spyOnPrivateMethods() { + spyOn((themeService as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); + spyOn((themeService as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); + spyOn((themeService as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); + } + + describe('when no resolved action is present', () => { + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + }); + + it('should set the theme it receives from the route url', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when no themes are present', () => { + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + (themeService as any).themes = []; + }); + + it('should not dispatch any action', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => { + expect((themeService as any).store.dispatch).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should return false', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + }); + + describe('when a dso is present in the snapshot\'s data', () => { + let snapshot; + + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + snapshot = Object.assign({ + data: { + dso: createSuccessfulRemoteDataObject(dso) + } + }); + }); + + it('should match the theme to the dso', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled(); + done(); + }); + }); + + it('should set the theme it receives from the data dso', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when a scope is present in the snapshot\'s parameters', () => { + let snapshot; + + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + snapshot = Object.assign({ + queryParams: { + scope: mockCommunity.uuid + } + }); + }); + + it('should match the theme to the dso found through the scope', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled(); + done(); + }); + }); + + it('should set the theme it receives from the dso found through the scope', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + }); + + describe('private functions', () => { + beforeEach(() => { + setupServiceWithActions(hot('-', {})); + }); + + describe('getActionForMatch', () => { + it('should return a SET action if the new theme differs from the current theme', () => { + const theme = new Theme({ name: 'new-theme' }); + expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); + }); + + it('should return an empty action if the new theme equals the current theme', () => { + const theme = new Theme({ name: 'old-theme' }); + expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); + }); + }); + + describe('matchThemeToDSOs', () => { + let themes: Theme[]; + let nonMatchingTheme: Theme; + let itemMatchingTheme: Theme; + let communityMatchingTheme: Theme; + let dsos: DSpaceObject[]; + + beforeEach(() => { + nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { + matches: () => false + }); + itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { + matches: (url, dso) => (dso as any).type === ITEM.value + }); + communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { + matches: (url, dso) => (dso as any).type === COMMUNITY.value + }); + dsos = [ + Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }), + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'community-uuid', + }), + ]; + }); + + describe('when no themes match any of the DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme ]; + themeService.themes = themes; + }); + + it('should return undefined', () => { + expect((themeService as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); + }); + }); + + describe('when one of the themes match a DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme, itemMatchingTheme ]; + themeService.themes = themes; + }); + + it('should return the matching theme', () => { + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + }); + }); + + describe('when multiple themes match some of the DSOs', () => { + it('should return the first matching theme', () => { + themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; + themeService.themes = themes; + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + + themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; + themeService.themes = themes; + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + }); + }); + }); + + describe('getAncestorDSOs', () => { + it('should return an array of the provided DSO and its ancestors', (done) => { + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + _links: { owningCollection: { href: 'owning-collection-link' } }, + }); + + observableOf(dso).pipe( + (themeService as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso, ...ancestorDSOs]); + done(); + }); + }); + + it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { + const dso = { + type: ITEM.value, + uuid: 'item-uuid', + }; + + observableOf(dso).pipe( + (themeService as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso]); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 7b0af93e04..4a4f6ae986 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,10 +1,25 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; -import { Observable } from 'rxjs/internal/Observable'; +import { EMPTY, Observable, of as observableOf } from 'rxjs'; import { ThemeState } from './theme.reducer'; -import { SetThemeAction } from './theme.actions'; -import { take } from 'rxjs/operators'; -import { hasValue } from '../empty.util'; +import { SetThemeAction, ThemeActionTypes } from './theme.actions'; +import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload +} from '../../core/shared/operators'; +import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; +import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action'; +import { followLink } from '../utils/follow-link-config.model'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { environment } from '../../../environments/environment'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator'; export const themeStateSelector = createFeatureSelector('theme'); @@ -17,9 +32,29 @@ export const currentThemeSelector = createSelector( providedIn: 'root' }) export class ThemeService { + /** + * The list of configured themes + */ + themes: Theme[]; + + /** + * True if at least one theme depends on the route + */ + hasDynamicTheme: boolean; + constructor( private store: Store, + private linkService: LinkService, + private dSpaceObjectDataService: DSpaceObjectDataService, + @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig ) { + // Create objects from the theme configs in the environment file + this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); + this.hasDynamicTheme = environment.themes.some((themeConfig: any) => + hasValue(themeConfig.regex) || + hasValue(themeConfig.handle) || + hasValue(themeConfig.uuid) + ); } setTheme(newName: string) { @@ -43,4 +78,174 @@ export class ThemeService { ); } + /** + * Determine whether or not the theme needs to change depending on the current route's URL and snapshot data + * If the snapshot contains a dso, this will be used to match a theme + * If the snapshot contains a scope parameters, this will be used to match a theme + * Otherwise the URL is matched against + * If none of the above find a match, the theme doesn't change + * @param currentRouteUrl + * @param activatedRouteSnapshot + * @return Observable boolean emitting whether or not the theme has been changed + */ + updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable { + // and the current theme from the store + const currentTheme$: Observable = this.store.pipe(select(currentThemeSelector)); + + const action$ = currentTheme$.pipe( + switchMap((currentTheme: string) => { + const snapshotWithData = this.findRouteData(activatedRouteSnapshot); + if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { + if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) { + const dsoRD: RemoteData = snapshotWithData.data.dso; + if (dsoRD.hasSucceeded) { + // Start with the resolved dso and go recursively through its parents until you reach the top-level community + return observableOf(dsoRD.payload).pipe( + this.getAncestorDSOs(), + map((dsos: DSpaceObject[]) => { + const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + return this.getActionForMatch(dsoMatch, currentTheme); + }) + ); + } + } + if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) { + const dsoFromScope$: Observable> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope); + // Start with the resolved dso and go recursively through its parents until you reach the top-level community + return dsoFromScope$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + this.getAncestorDSOs(), + map((dsos: DSpaceObject[]) => { + const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + return this.getActionForMatch(dsoMatch, currentTheme); + }) + ); + } + + // check whether the route itself matches + const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); + + return [this.getActionForMatch(routeMatch, currentTheme)]; + } + + // If there are no themes configured, do nothing + return [new NoOpAction()]; + }), + take(1), + ); + + action$.pipe( + filter((action) => action.type !== NO_OP_ACTION_TYPE), + ).subscribe((action) => { + this.store.dispatch(action); + }); + + return action$.pipe( + map((action) => action.type === ThemeActionTypes.SET), + ); + } + + /** + * Find a DSpaceObject in one of the provided route snapshots their data + * Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one + * @param routes + */ + findRouteData(...routes: ActivatedRouteSnapshot[]) { + const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso)); + if (hasValue(result)) { + return result; + } else { + const nextLevelRoutes = routes + .map((route: ActivatedRouteSnapshot) => route.children) + .reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]); + if (isNotEmpty(nextLevelRoutes)) { + return this.findRouteData(...nextLevelRoutes); + } else { + return undefined; + } + } + } + + /** + * An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as + * input. The initial DSpaceObject will be the first element of the output array, followed by + * its parent, its grandparent etc + * + * @private + */ + private getAncestorDSOs() { + return (source: Observable): Observable => + source.pipe( + expand((dso: DSpaceObject) => { + // Check if the dso exists and has a parent link + if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') { + const linkName = (dso as any).getParentLinkKey(); + // If it does, retrieve it. + return this.linkService.resolveLinkWithoutAttaching(dso, followLink(linkName)).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (hasValue(rd.payload)) { + // If there's a parent, use it for the next iteration + return rd.payload; + } else { + // If there's no parent, or an error, return null, which will stop recursion + // in the next iteration + return null; + } + }), + ); + } + + // The current dso has no value, or no parent. Return EMPTY to stop recursion + return EMPTY; + }), + // only allow through DSOs that have a value + filter((dso: DSpaceObject) => hasValue(dso)), + // Wait for recursion to complete, and emit all results at once, in an array + toArray() + ); + } + + /** + * return the action to dispatch based on the given matching theme + * + * @param newTheme The theme to create an action for + * @param currentThemeName The name of the currently active theme + * @private + */ + private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction { + if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) { + // If we have a match, and it isn't already the active theme, set it as the new theme + return new SetThemeAction(newTheme.config.name); + } else { + // Otherwise, do nothing + return new NoOpAction(); + } + } + + /** + * Check the given DSpaceObjects in order to see if they match the configured themes in order. + * If a match is found, the matching theme is returned + * + * @param dsos The DSpaceObjects to check + * @param currentRouteUrl The url for the current route + * @private + */ + private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { + // iterate over the themes in order, and return the first one that matches + return this.themes.find((theme: Theme) => { + // iterate over the dsos's in order (most specific one first, so Item, Collection, + // Community), and return the first one that matches the current theme + const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); + return hasValue(match); + }); + } + + /** + * Searches for a ThemeConfig by its name; + */ + getThemeConfigFor(themeName: string): ThemeConfig { + return this.gtcf(themeName); + } } diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts index abaee28a29..1db6de072d 100644 --- a/src/app/shared/theme-support/themed.component.spec.ts +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive'; import { ThemeService } from './theme.service'; import { getMockThemeService } from '../mocks/theme-service.mock'; import { TestComponent } from './test/test.component.spec'; +import { ThemeConfig } from '../../../config/theme.model'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -32,8 +33,8 @@ describe('ThemedComponent', () => { let fixture: ComponentFixture; let themeService: ThemeService; - function setupTestingModuleForTheme(theme: string) { - themeService = getMockThemeService(theme); + function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) { + themeService = getMockThemeService(theme, themes); TestBed.configureTestingModule({ imports: [], declarations: [TestThemedComponent, VarDirective], @@ -44,17 +45,20 @@ describe('ThemedComponent', () => { }).compileComponents(); } + function initComponent() { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + spyOn(component as any, 'importThemedComponent').and.callThrough(); + component.testInput = 'changed'; + fixture.detectChanges(); + } + describe('when the current theme matches a themed component', () => { beforeEach(waitForAsync(() => { setupTestingModuleForTheme('custom'); })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); - }); + beforeEach(initComponent); it('should set compRef to the themed component', waitForAsync(() => { fixture.whenStable().then(() => { @@ -70,28 +74,127 @@ describe('ThemedComponent', () => { }); describe('when the current theme doesn\'t match a themed component', () => { - beforeEach(waitForAsync(() => { - setupTestingModuleForTheme('non-existing-theme'); - })); + describe('and it doesn\'t extend another theme', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('non-existing-theme'); + })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - it('should set compRef to the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.type).toEqual('default'); - }); - })); + describe('and it extends another theme', () => { + describe('that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'non-existing-theme' }, + ]); + })); - it('should sync up this component\'s input with the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.testInput).toEqual('changed'); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - })); + + describe('that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'non-existing-theme' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + }); }); }); /* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/theme-support/themed.component.ts b/src/app/shared/theme-support/themed.component.ts index 1a41327209..2ff0713f46 100644 --- a/src/app/shared/theme-support/themed.component.ts +++ b/src/app/shared/theme-support/themed.component.ts @@ -11,9 +11,8 @@ import { OnChanges } from '@angular/core'; import { hasValue, isNotEmpty } from '../empty.util'; -import { Subscription } from 'rxjs'; +import { from as fromPromise, Observable, of as observableOf, Subscription } from 'rxjs'; import { ThemeService } from './theme.service'; -import { fromPromise } from 'rxjs/internal-compatibility'; import { catchError, switchMap, map } from 'rxjs/operators'; import { GenericConstructor } from '../../core/shared/generic-constructor'; @@ -69,31 +68,27 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges this.lazyLoadSub.unsubscribe(); } - this.lazyLoadSub = - fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe( - // if there is no themed version of the component an exception is thrown, - // catch it and return null instead - catchError(() => [null]), - switchMap((themedFile: any) => { - if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { - // if the file is not null, and exports a component with the specified name, - // return that component - return [themedFile[this.getComponentName()]]; - } else { - // otherwise import and return the default component - return fromPromise(this.importUnthemedComponent()).pipe( - map((unthemedFile: any) => { - return unthemedFile[this.getComponentName()]; - }) - ); - } - }), - ).subscribe((constructor: GenericConstructor) => { - const factory = this.resolver.resolveComponentFactory(constructor); - this.compRef = this.vcr.createComponent(factory); - this.connectInputsAndOutputs(); - this.cdr.markForCheck(); - }); + this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe( + switchMap((themedFile: any) => { + if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { + // if the file is not null, and exports a component with the specified name, + // return that component + return [themedFile[this.getComponentName()]]; + } else { + // otherwise import and return the default component + return fromPromise(this.importUnthemedComponent()).pipe( + map((unthemedFile: any) => { + return unthemedFile[this.getComponentName()]; + }) + ); + } + }), + ).subscribe((constructor: GenericConstructor) => { + const factory = this.resolver.resolveComponentFactory(constructor); + this.compRef = this.vcr.createComponent(factory); + this.connectInputsAndOutputs(); + this.cdr.markForCheck(); + }); } protected destroyComponentInstance(): void { @@ -113,4 +108,32 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges }); } } + + /** + * Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}. + * Recurse until we succeed or when until we run out of themes to fall back to. + * + * @param themeName The name of the theme to check + * @param checkedThemeNames The list of theme names that are already checked + * @private + */ + private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable { + if (isNotEmpty(themeName)) { + return fromPromise(this.importThemedComponent(themeName)).pipe( + catchError(() => { + // Try the next ancestor theme instead + const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends; + const nextCheckedThemeNames = [...checkedThemeNames, themeName]; + if (checkedThemeNames.includes(nextTheme)) { + throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> ')); + } else { + return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames); + } + }), + ); + } else { + // If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed + return observableOf(null); + } + } } diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html index caa94d1709..109473bc97 100644 --- a/src/app/shared/uploader/uploader.component.html +++ b/src/app/shared/uploader/uploader.component.html @@ -19,11 +19,14 @@ (fileOver)="fileOverBase($event)" class="well ds-base-drop-zone mt-1 mb-3 text-muted">
- {{dropMsg | translate}} {{'uploader.or' | translate}} - + + + {{dropMsg | translate}}{{'uploader.or' | translate}} + + +
diff --git a/src/app/shared/utils/encode-decode.util.spec.ts b/src/app/shared/utils/encode-decode.util.spec.ts deleted file mode 100644 index c3039c482e..0000000000 --- a/src/app/shared/utils/encode-decode.util.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Base64EncodeUrl } from './encode-decode.util'; - -describe('Encode/Decode Utils', () => { - const strng = '+string+/=t-'; - const encodedStrng = '%2Bstring%2B%2F%3Dt-'; - - it('should return encoded string', () => { - expect(Base64EncodeUrl(strng)).toBe(encodedStrng); - }); -}); diff --git a/src/app/shared/utils/encode-decode.util.ts b/src/app/shared/utils/encode-decode.util.ts deleted file mode 100644 index e21034b7bd..0000000000 --- a/src/app/shared/utils/encode-decode.util.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * use this to make a Base64 encoded string URL friendly, - * i.e. '+' and '/' are replaced with special percent-encoded hexadecimal sequences - * - * @param {String} str the encoded string - * @returns {String} the URL friendly encoded String - */ -export function Base64EncodeUrl(str): string { - return str.replace(/\+/g, '%2B').replace(/\//g, '%2F').replace(/\=/g, '%3D'); -} diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index c415b89b81..9a35c2dfe1 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { debounceTime, filter, switchMap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; @@ -16,7 +16,6 @@ import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { getAllSucceededRemoteData } from '../../core/shared/operators'; import { ItemDataService } from '../../core/data/item-data.service'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; import { SubmissionError } from '../objects/submission-objects.reducer'; import parseSectionErrors from '../utils/parseSectionErrors'; diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss index 56d6288764..c5e142b89c 100644 --- a/src/app/submission/form/submission-form.component.scss +++ b/src/app/submission/form/submission-form.component.scss @@ -17,3 +17,23 @@ z-index: var(--ds-submission-footer-z-index); } +.btn-link-focus { + // behave as btn-link but does not override box-shadow of btn-link:focus + font-weight: $font-weight-normal; + color: $link-color; + text-decoration: $link-decoration; + @include hover { + color: $link-hover-color; + text-decoration: $link-hover-decoration; + } + &:disabled, + &.disabled { + color: $btn-link-disabled-color; + pointer-events: none; + } + + &:focus, + &.focus { + text-decoration: $link-hover-decoration; + } +} diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html index 73b41378c8..6fb6ab3382 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html @@ -1,11 +1,16 @@
- diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts index 1247eda0dc..cd7fa86b0a 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts @@ -1,10 +1,11 @@ -import { Component, NO_ERRORS_SCHEMA, EventEmitter } from '@angular/core'; -import { waitForAsync, TestBed, ComponentFixture, inject } from '@angular/core/testing'; +import { Component, EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { createTestComponent } from '../../../shared/testing/utils.test'; import { SubmissionImportExternalCollectionComponent } from './submission-import-external-collection.component'; import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { By } from '@angular/platform-browser'; describe('SubmissionImportExternalCollectionComponent test suite', () => { let comp: SubmissionImportExternalCollectionComponent; @@ -76,6 +77,46 @@ describe('SubmissionImportExternalCollectionComponent test suite', () => { expect(compAsAny.activeModal.dismiss).toHaveBeenCalled(); }); + + it('should be in loading state when search is not completed', () => { + comp.loading = null; + expect(comp.isLoading()).toBeFalse(); + + comp.loading = true; + expect(comp.isLoading()).toBeTrue(); + + comp.loading = false; + expect(comp.isLoading()).toBeFalse(); + }); + + it('should set loading variable to false on searchComplete event', () => { + comp.loading = null; + + comp.searchComplete(); + expect(comp.loading).toBe(false); + + }); + + it('should emit theOnlySelectable', () => { + spyOn(comp.selectedEvent, 'emit').and.callThrough(); + + const selected: any = {}; + comp.theOnlySelectable(selected); + + expect(comp.selectedEvent.emit).toHaveBeenCalledWith(selected); + }); + + it('dropdown should be invisible when the component is loading', fakeAsync(() => { + + spyOn(comp, 'isLoading').and.returnValue(true); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + const dropdownMenu = fixture.debugElement.query(By.css('ds-collection-dropdown')).nativeElement; + expect(dropdownMenu.classList).toContain('d-none'); + }); + })); + }); }); diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts index cbac0cb710..e35bde03cf 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts @@ -1,4 +1,4 @@ -import { Component, Output, EventEmitter } from '@angular/core'; +import { Component, EventEmitter, Output } from '@angular/core'; import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -16,6 +16,16 @@ export class SubmissionImportExternalCollectionComponent { */ @Output() public selectedEvent = new EventEmitter(); + /** + * If present this value is used to filter collection list by entity type + */ + public entityType: string; + + /** + * If collection searching is pending or not + */ + public loading = true; + /** * Initialize the component variables. * @param {NgbActiveModal} activeModal @@ -37,4 +47,28 @@ export class SubmissionImportExternalCollectionComponent { public closeCollectionModal(): void { this.activeModal.dismiss(false); } + + /** + * Propagate the onlySelectable collection + * @param theOnlySelectable + */ + public theOnlySelectable(theOnlySelectable: CollectionListEntry) { + this.selectedEvent.emit(theOnlySelectable); + } + + /** + * Set the hasChoice state + * @param hasChoice + */ + public searchComplete() { + this.loading = false; + } + + /** + * If the component is in loading state. + */ + public isLoading(): boolean { + return !!this.loading; + } + } diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html index 83c1ed82b6..bbb0dbcc94 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html @@ -1,5 +1,5 @@