Merge branch 'main' into CST-4499

# Conflicts:
#	src/app/item-page/item-page.module.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Davide Negretti
2021-10-15 22:35:27 +02:00
119 changed files with 4358 additions and 1601 deletions

View File

@@ -16,9 +16,9 @@ jobs:
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false DSPACE_REST_SSL: false
# When Chrome version is specified, we pin to a specific version of Chrome & ChromeDriver # When Chrome version is specified, we pin to a specific version of Chrome
# Comment this out to use the latest release of both. # Comment this out to use the latest release
CHROME_VERSION: "90.0.4430.212-1" #CHROME_VERSION: "90.0.4430.212-1"
strategy: strategy:
# Create a matrix of Node versions to test against (in parallel) # Create a matrix of Node versions to test against (in parallel)
matrix: matrix:
@@ -66,12 +66,6 @@ jobs:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn- restore-keys: ${{ runner.os }}-yarn-
- name: Install latest ChromeDriver compatible with installed Chrome
# needs to be npm, the --detect_chromedriver_version flag doesn't work with yarn global
run: |
npm install -g chromedriver --detect_chromedriver_version
chromedriver -v
- name: Install Yarn dependencies - name: Install Yarn dependencies
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
@@ -99,23 +93,40 @@ jobs:
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
docker container ls docker container ls
# Wait until the REST API returns a 200 response (or for a max of 30 seconds) # Run integration tests via Cypress.io
# https://github.com/nev7n/wait_for_response # https://github.com/cypress-io/github-action
- name: Wait for DSpace REST Backend to be ready (for e2e tests) # (NOTE: to run these e2e tests locally, just use 'ng e2e')
uses: nev7n/wait_for_response@v1
with:
# We use the 'sites' endpoint to also ensure the database is ready
url: 'http://localhost:8080/server/api/core/sites'
responseCode: 200
timeout: 30000
- name: Get DSpace REST Backend info/properties
run: curl http://localhost:8080/server/api
- name: Run e2e tests (integration tests) - name: Run e2e tests (integration tests)
run: | uses: cypress-io/github-action@v2
chromedriver --url-base='/wd/hub' --port=4444 & with:
yarn run e2e:ci # Run tests in Chrome, headless mode
browser: chrome
headless: true
# Start app before running tests (will be stopped automatically after tests finish)
start: yarn run serve:ssr
# Wait for backend & frontend to be available
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000
# Wait for 2 mins max for everything to respond
wait-on-timeout: 120
# Cypress always creates a video of all e2e tests (whether they succeeded or failed)
# Save those in an Artifact
- name: Upload e2e test videos to Artifacts
uses: actions/upload-artifact@v2
if: always()
with:
name: e2e-test-videos
path: cypress/videos
# If e2e tests fail, Cypress creates a screenshot of what happened
# Save those in an Artifact
- name: Upload e2e test failure screenshots to Artifacts
uses: actions/upload-artifact@v2
if: failure()
with:
name: e2e-test-screenshots
path: cypress/screenshots
# Start up the app with SSR enabled (run in background) # Start up the app with SSR enabled (run in background)
- name: Start app in SSR (server-side rendering) mode - name: Start app in SSR (server-side rendering) mode

163
README.md
View File

@@ -61,14 +61,17 @@ Table of Contents
- [Introduction to the technology](#introduction-to-the-technology) - [Introduction to the technology](#introduction-to-the-technology)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Installing](#installing) - [Installing](#installing)
- [Configuring](#configuring) - [Configuring](#configuring)
- [Running the app](#running-the-app) - [Running the app](#running-the-app)
- [Running in production mode](#running-in-production-mode) - [Running in production mode](#running-in-production-mode)
- [Deploy](#deploy) - [Deploy](#deploy)
- [Running the application with Docker](#running-the-application-with-docker) - [Running the application with Docker](#running-the-application-with-docker)
- [Cleaning](#cleaning) - [Cleaning](#cleaning)
- [Testing](#testing) - [Testing](#testing)
- [Test a Pull Request](#test-a-pull-request) - [Test a Pull Request](#test-a-pull-request)
- [Unit Tests](#unit-tests)
- [E2E Tests](#e2e-tests)
- [Writing E2E Tests](#writing-e2e-tests)
- [Documentation](#documentation) - [Documentation](#documentation)
- [Other commands](#other-commands) - [Other commands](#other-commands)
- [Recommended Editors/IDEs](#recommended-editorsides) - [Recommended Editors/IDEs](#recommended-editorsides)
@@ -104,9 +107,9 @@ Default configuration file is located in `src/environments/` folder.
To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point.
- Create a new `environment.dev.ts` file in `src/environments/` for a `development` environment; - Create a new `environment.dev.ts` file in `src/environments/` for a `development` environment;
- Create a new `environment.prod.ts` file in `src/environments/` for a `production` environment; - Create a new `environment.prod.ts` file in `src/environments/` for a `production` environment;
The server settings can also be overwritten using an environment file. The server settings can also be overwritten using an environment file.
This file should be called `.env` and be placed in the project root. This file should be called `.env` and be placed in the project root.
@@ -125,7 +128,7 @@ DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false]
``` ```
The same settings can also be overwritten by setting system environment variables instead, E.g.: The same settings can also be overwritten by setting system environment variables instead, E.g.:
```bash ```bash
export DSPACE_HOST=api7.dspace.org export DSPACE_HOST=api7.dspace.org
``` ```
@@ -140,7 +143,7 @@ To use environment variables in a UI component, use:
import { environment } from '../environment.ts'; import { environment } from '../environment.ts';
``` ```
This file is generated by the script located in `scripts/set-env.ts`. This script will run automatically before every build, or can be manually triggered using the appropriate `config` script in `package.json` This file is generated by the script located in `scripts/set-env.ts`. This script will run automatically before every build, or can be manually triggered using the appropriate `config` script in `package.json`
Running the app Running the app
@@ -209,34 +212,66 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
### Unit Tests ### Unit Tests
Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`. Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
The default browser is Google Chrome. The default browser is Google Chrome.
Place your tests in the same location of the application source code files that they test. Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
and run: `yarn run test` and run: `yarn test`
### E2E test If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file:`./protractor.conf.js` Protractor is installed as 'local' as a dev dependency. ### E2E Tests
If you are going to use a remote test enviroment you need to edit the './e2e//protractor.conf.js'. Follow the instructions you will find inside it. E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Configuration for cypress can be found in the `cypress.json` file in the root directory.
The default browser is Google Chrome. The test files can be found in the `./cypress/integration/` folder.
Place your tests at the following path: `./e2e` Before you can run e2e tests, you MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `environment.prod.ts` or `environment.common.ts`. You may override this using env variables, see [Configuring](#configuring).
and run: `ng e2e` Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results.
### Continuous Integration (CI) Test #### Writing E2E Tests
To run all the tests (e.g.: to run tests with Continuous Integration software) you can execute:`yarn run ci` Keep in mind that this command prerequisites are the sum of unit test and E2E tests. All E2E tests must be created under the `./cypress/integration/` folder, and must end in `.spec.ts`. Subfolders are allowed.
* The easiest way to start creating new tests is by running `ng e2e`. This builds the app and brings up Cypress.
* From here, if you are editing an existing test file, you can either open it in your IDE or run it first to see what it already does.
* To create a new test file, click `+ New Spec File`. Choose a meaningful name ending in `spec.ts` (Please make sure it ends in `.ts` so that it's a Typescript file, and not plain Javascript)
* Start small. Add a basic `describe` and `it` which just [cy.visit](https://docs.cypress.io/api/commands/visit) the page you want to test. For example:
```
describe('Community/Collection Browse Page', () => {
it('should exist as a page', () => {
cy.visit('/community-list');
});
});
```
* Run your test file from the Cypress window. This starts the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) in a new browser window.
* In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_.
* From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page.
* Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector
* Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc.
* Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions.
* Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly.
* Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests.
_Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can help prompt/autocomplete your Cypress commands._
More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress.
### Learning how to build tests
See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips.
Documentation Documentation
-------------- --------------
See [`./docs`](docs) for further documentation. Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/
Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase.
### Building code documentation ### Building code documentation
@@ -259,8 +294,6 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've
- Free - Free
- [Visual Studio Code](https://code.visualstudio.com/) - [Visual Studio Code](https://code.visualstudio.com/)
- [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) - [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome)
- [Atom](https://atom.io/)
- [TypeScript plugin](https://atom.io/packages/atom-typescript)
- Paid - Paid
- [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/) - [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/)
- [Sublime Text](http://www.sublimetext.com/3) - [Sublime Text](http://www.sublimetext.com/3)
@@ -282,95 +315,43 @@ dspace-angular
│   ├── environment.default.js * Default configuration files │   ├── environment.default.js * Default configuration files
│   └── environment.test.js * Test configuration files │   └── environment.test.js * Test configuration files
├── docs * Folder for documentation ├── docs * Folder for documentation
├── e2e * Folder for e2e test files ├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests
│   ├── app.e2e-spec.ts * │   ├── integration * Folder for e2e/integration test files
│   ├── app.po.ts * │   ├── fixtures * Folder for any fixtures needed by e2e tests
│   ├── pagenotfound * │   ├── plugins * Folder for Cypress plugins (if any)
│   │   ├── pagenotfound.e2e-spec.ts * │   ├── support * Folder for global e2e test actions/commands (run for all tests)
│   │   └── pagenotfound.po.ts *
│   └── tsconfig.json * TypeScript configuration file for e2e tests │   └── tsconfig.json * TypeScript configuration file for e2e tests
├── karma.conf.js * Karma configuration file for Unit Test ├── karma.conf.js * Karma configuration file for Unit Test
├── nodemon.json * Nodemon (https://nodemon.io/) configuration ├── nodemon.json * Nodemon (https://nodemon.io/) configuration
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. ├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
├── postcss.config.js * PostCSS (http://postcss.org/) configuration file ├── postcss.config.js * PostCSS (http://postcss.org/) configuration file
├── protractor.conf.js *
├── resources * Folder for static resources
│   ├── data * Folder for static data
│   │   └── en * Folder for i18n English data
│   ├── i18n * Folder for i18n translations
│   │   └── en.json * i18n translations for English
│   └── images * Folder for images
│   ├── dspace-logo-old.png *
│   ├── dspace-logo.png *
│   └── favicon.ico *
├── rollup.config.js * Rollup (http://rollupjs.org/) configuration
├── spec-bundle.js *
├── src * The source of the application ├── src * The source of the application
│   ├── app * │   ├── app * The source code of the application, subdivided by module/page.
│   │   ├── app-routing.module.ts * │   ├── assets * Folder for static resources
│   │   ├── app.component.html * │   │   ├── fonts * Folder for fonts
│   │   ├── app.component.scss * │   │   ├── i18n * Folder for i18n translations
│   │   ├── app.component.spec.ts * │   | └── en.json5 * i18n translations for English
│   │   ── app.component.ts * │   │   ── images * Folder for images
│   │   ├── app.effects.ts *
│   │   ├── app.module.ts *
│   │   ├── app.reducer.ts *
│   │   ├── browser-app.module.ts * The root module for the client
│   │   ├── +collection-page * Lazily loaded route for collection module
│   │   ├── +community-page * Lazily loaded route for community module
│   │   ├── core *
│   │   ├── header *
│   │   ├── +home * Lazily loaded route for home module
│   │   ├── +item-page * Lazily loaded route for item module
│   │   ├── object-list *
│   │   ├── pagenotfound *
│   │   ├── server-app.module.ts * The root module for the server
│   │   ├── shared *
│   │   ├── store.actions.ts *
│   │   ├── store.effects.ts *
│   │   ├── thumbnail *
│   │   └── typings.d.ts * File that allows you to add custom typings for libraries without TypeScript support
│   ├── backend * Folder containing a mock of the REST API, hosted by the express server │   ├── backend * Folder containing a mock of the REST API, hosted by the express server
│   │   ├── api.ts *
│   │   ├── cache.ts *
│   │   ├── data *
│   │   └── db.ts *
│   ├── config * │   ├── config *
│   │   ├── cache-config.interface.ts *
│   │   ├── config.interface.ts *
│   │   ├── global-config.interface.ts *
│   │   ├── server-config.interface.ts *
│   │   └── universal-config.interface.ts *
│   ├── config.ts * File that loads environmental and shareable settings and makes them available to app components
│   ├── index.csr.html * The index file for client side rendering fallback │   ├── index.csr.html * The index file for client side rendering fallback
│   ├── index.html * The index file │   ├── index.html * The index file
│   ├── main.browser.ts * The bootstrap file for the client │   ├── main.browser.ts * The bootstrap file for the client
│   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server │   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
│   ├── robots.txt * The robots.txt file
│   ├── modules * │   ├── modules *
│   │   ├── cookies *
│   │   ├── data-loader *
│   │   ├── transfer-http *
│   │   ├── transfer-state *
│   │   ├── transfer-store *
│   │   └── translate-universal-loader.ts *
│   ├── routes.ts * The routes file for the server
│   ├── styles * Folder containing global styles │   ├── styles * Folder containing global styles
│   │   ├── _mixins.scss * │   └── themes * Folder containing available themes
│      ── variables.scss * Global sass variables file │      ── custom * Template folder for creating a custom theme
│   ├── tsconfig.browser.json * TypeScript config for the client build │      └── dspace * Default 'dspace' theme
│   ├── tsconfig.server.json * TypeScript config for the server build
│   └── tsconfig.test.json * TypeScript config for the test build
├── tsconfig.json * TypeScript config ├── tsconfig.json * TypeScript config
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
├── typedoc.json * TYPEDOC configuration ├── typedoc.json * TYPEDOC configuration
├── webpack * Webpack (https://webpack.github.io/) config directory ├── webpack * Webpack (https://webpack.github.io/) config directory
│   ├── webpack.aot.js * Webpack (https://webpack.github.io/) config for AoT build │   ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for client build
│   ├── webpack.client.js * Webpack (https://webpack.github.io/) config for client build │   ├── webpack.common.ts *
│   ├── webpack.common.js * │   ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for production build
│   ── webpack.prod.js * Webpack (https://webpack.github.io/) config for production build │   ── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build
│   ├── webpack.server.js * Webpack (https://webpack.github.io/) config for server build
│   └── webpack.test.js * Webpack (https://webpack.github.io/) config for test build
├── webpack.config.ts *
└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) └── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock)
``` ```

View File

@@ -147,7 +147,7 @@
"tsConfig": [ "tsConfig": [
"tsconfig.app.json", "tsconfig.app.json",
"tsconfig.spec.json", "tsconfig.spec.json",
"e2e/tsconfig.json" "cypress/tsconfig.json"
], ],
"exclude": [ "exclude": [
"**/node_modules/**" "**/node_modules/**"
@@ -155,10 +155,11 @@
} }
}, },
"e2e": { "e2e": {
"builder": "@angular-devkit/build-angular:protractor", "builder": "@cypress/schematic:cypress",
"options": { "options": {
"protractorConfig": "e2e/protractor.conf.js", "devServerTarget": "dspace-angular:serve",
"devServerTarget": "dspace-angular:serve" "watch": true,
"headless": false
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -215,9 +216,27 @@
"configurations": { "configurations": {
"production": {} "production": {}
} }
},
"cypress-run": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "dspace-angular:serve"
},
"configurations": {
"production": {
"devServerTarget": "dspace-angular:serve:production"
}
}
},
"cypress-open": {
"builder": "@cypress/schematic:cypress",
"options": {
"watch": true,
"headless": false
}
} }
} }
} }
}, },
"defaultProject": "dspace-angular" "defaultProject": "dspace-angular"
} }

9
cypress.json Normal file
View File

@@ -0,0 +1,9 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4000"
}

View File

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

View File

@@ -0,0 +1,37 @@
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', () => {
// // first must inject Axe into current page
// cy.injectAxe();
// // Analyze entire page for accessibility issues
// // NOTE: this test checks accessibility of header/footer as well
// cy.checkA11y({
// exclude: [
// ['#klaro'], // Klaro plugin (privacy policy popup) has color contrast issues
// ['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
// ['.dropdownLogin'] // "Log in" link in header has color contrast issues
// ],
// });
// });
});

View File

@@ -0,0 +1,15 @@
describe('Item Page', () => {
const ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
const ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067';
it('should contain element ds-item-page when navigating to an item page', () => {
cy.visit(ENTITYPAGE);
cy.get('ds-item-page').should('exist');
});
// 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);
});
});

View File

@@ -0,0 +1,25 @@
describe('Item Statistics Page', () => {
const ITEMUUID = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
const ITEMSTATISTICSPAGE = '/statistics/items/' + ITEMUUID;
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 the item statistics page url when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + ITEMUUID + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + ITEMUUID + '_TotalVisitsPerMonth').should('exist');
});
});

View File

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

View File

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

View File

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

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

@@ -0,0 +1,5 @@
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
/* tslint:disable:no-empty */
module.exports = (on, config) => { };
/* tslint:enable:no-empty */

View File

@@ -0,0 +1,43 @@
// ***********************************************
// This example namespace declaration will help
// with Intellisense and code completion in your
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

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

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

12
cypress/tsconfig.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@
"pretest:headless": "yarn run config:test", "pretest:headless": "yarn run config:test",
"prebuild:prod": "yarn run config:prod", "prebuild:prod": "yarn run config:prod",
"pree2e": "yarn run config:prod", "pree2e": "yarn run config:prod",
"pree2e:ci": "yarn run config:prod",
"start": "yarn run start:prod", "start": "yarn run start:prod",
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"start:dev": "npm-run-all --parallel config:dev:watch serve", "start:dev": "npm-run-all --parallel config:dev:watch serve",
@@ -32,7 +31,6 @@
"lint": "ng lint", "lint": "ng lint",
"lint-fix": "ng lint --fix=true", "lint-fix": "ng lint --fix=true",
"e2e": "ng e2e", "e2e": "ng e2e",
"e2e:ci": "ng e2e --webdriver-update=false --protractor-config=./e2e/protractor-ci.conf.js",
"compile:server": "webpack --config webpack.server.config.js --progress --color", "compile:server": "webpack --config webpack.server.config.js --progress --color",
"serve:ssr": "node dist/server", "serve:ssr": "node dist/server",
"clean:coverage": "rimraf coverage", "clean:coverage": "rimraf coverage",
@@ -46,7 +44,10 @@
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node", "clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
"clean:env": "rimraf src/environments/environment.ts", "clean:env": "rimraf src/environments/environment.ts",
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts", "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"postinstall": "ngcc" "merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
"postinstall": "ngcc",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}, },
"browser": { "browser": {
"fs": false, "fs": false,
@@ -72,6 +73,7 @@
"@angular/platform-server": "~10.2.3", "@angular/platform-server": "~10.2.3",
"@angular/router": "~10.2.3", "@angular/router": "~10.2.3",
"@angularclass/bootloader": "1.0.1", "@angularclass/bootloader": "1.0.1",
"@kolkov/ngx-gallery": "^1.2.3",
"@ng-bootstrap/ng-bootstrap": "7.0.0", "@ng-bootstrap/ng-bootstrap": "7.0.0",
"@ng-dynamic-forms/core": "^12.0.0", "@ng-dynamic-forms/core": "^12.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0", "@ng-dynamic-forms/ui-ng-bootstrap": "^12.0.0",
@@ -123,8 +125,7 @@
"sortablejs": "1.13.0", "sortablejs": "1.13.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"webfontloader": "1.6.28", "webfontloader": "1.6.28",
"zone.js": "^0.10.3", "zone.js": "^0.10.3"
"@kolkov/ngx-gallery": "^1.2.3"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "10.0.1", "@angular-builders/custom-webpack": "10.0.1",
@@ -132,6 +133,7 @@
"@angular/cli": "~10.2.0", "@angular/cli": "~10.2.0",
"@angular/compiler-cli": "~10.2.3", "@angular/compiler-cli": "~10.2.3",
"@angular/language-service": "~10.2.3", "@angular/language-service": "~10.2.3",
"@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^5.5.0", "@fortawesome/fontawesome-free": "^5.5.0",
"@ngrx/store-devtools": "^10.0.1", "@ngrx/store-devtools": "^10.0.1",
"@ngtools/webpack": "10.2.0", "@ngtools/webpack": "10.2.0",
@@ -144,11 +146,14 @@
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.165", "@types/lodash": "^4.14.165",
"@types/node": "^14.14.9", "@types/node": "^14.14.9",
"axe-core": "^4.3.3",
"codelyzer": "^6.0.1", "codelyzer": "^6.0.1",
"compression-webpack-plugin": "^3.0.1", "compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"css-loader": "3.4.0", "css-loader": "3.4.0",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"cypress": "8.3.1",
"cypress-axe": "^0.13.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"fork-ts-checker-webpack-plugin": "^6.0.3", "fork-ts-checker-webpack-plugin": "^6.0.3",

View File

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

View File

@@ -52,15 +52,17 @@
<table id="groups" class="table table-striped table-hover table-bordered"> <table id="groups" class="table table-striped table-hover table-bordered">
<thead> <thead>
<tr> <tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page"> <tr *ngFor="let group of (groups | async)?.payload?.page">
<td>{{group.id}}</td> <td class="align-middle">{{group.id}}</td>
<td><a (click)="groupsDataService.startEditingNewGroup(group)" <td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td> [routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
describe('EPersonFormComponent', () => { describe('EPersonFormComponent', () => {
let component: EPersonFormComponent; let component: EPersonFormComponent;
@@ -99,12 +102,78 @@ describe('EPersonFormComponent', () => {
} }
}); });
return createSuccessfulRemoteDataObject$(ePerson); return createSuccessfulRemoteDataObject$(ePerson);
},
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
} }
}; };
builderService = getMockFormBuilderService(); builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) {
const controls = {};
formModel.forEach( model => {
model.parent = parent;
const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions);
});
return new FormGroup(controls, options);
},
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return {
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
};
},
getValidators(validatorsConfig) {
return this.getValidatorFns(validatorsConfig);
},
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
let validatorFns = [];
if (this.isObject(validatorsConfig)) {
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
const validatorConfigValue = validatorsConfig[validatorConfigKey];
if (this.isValidatorDescriptor(validatorConfigValue)) {
const descriptor = validatorConfigValue;
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
}
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
});
}
return validatorFns;
},
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
let validatorFn;
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
validatorFn = Validators[validatorName];
} else { // Custom Validators
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
} else if (validatorsToken) {
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
}
}
if (validatorFn === undefined) { // throw when no validator could be resolved
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
}
if (validatorArgs !== null) {
return validatorFn(validatorArgs);
}
return validatorFn;
},
isValidatorDescriptor(value) {
if (this.isObject(value)) {
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
}
return false;
},
isObject(value) {
return typeof value === 'object' && value !== null;
}
});
authService = new AuthServiceStub(); authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true),
}); });
groupsDataService = jasmine.createSpyObj('groupsDataService', { groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
@@ -146,6 +215,131 @@ describe('EPersonFormComponent', () => {
expect(component).toBeDefined(); expect(component).toBeDefined();
}); });
describe('check form validation', () => {
let firstName;
let lastName;
let email;
let canLogIn;
let requireCertificate;
let expected;
beforeEach(() => {
firstName = 'testName';
lastName = 'testLastName';
email = 'testEmail@test.com';
canLogIn = false;
requireCertificate = false;
expected = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: firstName
}
],
'eperson.lastname': [
{
value: lastName
},
],
},
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit');
component.canLogIn.value = canLogIn;
component.requireCertificate.value = requireCertificate;
fixture.detectChanges();
component.initialisePage();
fixture.detectChanges();
});
describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the lastName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeFalse();
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the email is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.required).toBeTrue();
});
}));
});
describe('after inserting information firstName,lastName and email not required', () => {
beforeEach(() => {
component.formGroup.controls.firstName.setValue('test');
component.formGroup.controls.lastName.setValue('test');
component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges();
});
it('firstName should be valid because the firstName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull();
});
}));
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.errors).toBeNull();
});
}));
it('email should be valid because the email is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeTrue();
expect(component.formGroup.controls.email.errors).toBeNull();
});
}));
});
describe('after inserting email wrong should show pattern validation error', () => {
beforeEach(() => {
component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges();
});
it('email should not be valid because the email pattern', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
});
}));
});
describe('after already utilized email', () => {
beforeEach(() => {
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(EPersonMock);
}
});
component.formGroup.controls.email.setValue('test@test.com');
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
fixture.detectChanges();
});
it('email should not be valid because email is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
});
}));
});
});
describe('when submitting the form', () => { describe('when submitting the form', () => {
let firstName; let firstName;
let lastName; let lastName;

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { import {
DynamicCheckboxModel, DynamicCheckboxModel,
@@ -8,7 +8,7 @@ import {
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators'; import { debounceTime, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
@@ -32,10 +32,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
templateUrl: './eperson-form.component.html' templateUrl: './eperson-form.component.html',
}) })
/** /**
* A form used for creating and editing EPeople * A form used for creating and editing EPeople
@@ -160,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
isImpersonated = false; isImpersonated = false;
constructor(public epersonService: EPersonDataService, /**
* Subscription to email field value change
*/
emailValueChangeSubscribe: Subscription;
constructor(protected changeDetectorRef: ChangeDetectorRef,
public epersonService: EPersonDataService,
public groupsDataService: GroupDataService, public groupsDataService: GroupDataService,
private formBuilderService: FormBuilderService, private formBuilderService: FormBuilderService,
private translateService: TranslateService, private translateService: TranslateService,
@@ -186,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page * This method will initialise the page
*/ */
initialisePage() { initialisePage() {
observableCombineLatest( observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`), this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -218,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
name: 'email', name: 'email',
validators: { validators: {
required: null, required: null,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
}, },
required: true, required: true,
errorMessages: {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail'
},
hint: emailHint hint: emailHint
}); });
this.canLogIn = new DynamicCheckboxModel( this.canLogIn = new DynamicCheckboxModel(
@@ -259,11 +272,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
canLogIn: eperson != null ? eperson.canLogIn : true, canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false requireCertificate: eperson != null ? eperson.requireCertificate : false
}); });
if (eperson === null && !!this.formGroup.controls.email) {
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
})); }));
const activeEPerson$ = this.epersonService.getActiveEPerson(); const activeEPerson$ = this.epersonService.getActiveEPerson();
this.groups = activeEPerson$.pipe( this.groups = activeEPerson$.pipe(
switchMap((eperson) => { switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1, currentPage: 1,
@@ -272,14 +292,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}), }),
switchMap(([eperson, findListOptions]) => { switchMap(([eperson, findListOptions]) => {
if (eperson != null) { if (eperson != null) {
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions); return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
} }
return observableOf(undefined); return observableOf(undefined);
}) })
); );
this.canImpersonate$ = activeEPerson$.pipe( this.canImpersonate$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined)) switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
} else {
return observableOf(false);
}
})
); );
this.canDelete$ = activeEPerson$.pipe( this.canDelete$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
@@ -342,10 +368,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
getFirstCompletedRemoteData() getFirstCompletedRemoteData()
).subscribe((rd: RemoteData<EPerson>) => { ).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', {name: ePersonToCreate.name})); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name }));
this.submitForm.emit(ePersonToCreate); this.submitForm.emit(ePersonToCreate);
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', {name: ePersonToCreate.name})); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name }));
this.cancelForm.emit(); this.cancelForm.emit();
} }
}); });
@@ -381,10 +407,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
const response = this.epersonService.updateEPerson(editedEperson); const response = this.epersonService.updateEPerson(editedEperson);
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', {name: editedEperson.name})); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name }));
this.submitForm.emit(editedEperson); this.submitForm.emit(editedEperson);
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', {name: editedEperson.name})); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name }));
this.cancelForm.emit(); this.cancelForm.emit();
} }
}); });
@@ -394,6 +420,87 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} }
} }
/**
* Event triggered when the user changes page
* @param event
*/
onPageChange(event) {
this.updateGroups({
currentPage: event,
elementsPerPage: this.config.pageSize
});
}
/**
* Start impersonating the EPerson
*/
impersonate() {
this.authService.impersonate(this.epersonInitial.id);
this.isImpersonated = true;
}
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.brandColor = 'danger';
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.submitForm.emit();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
}
this.cancelForm.emit();
});
}
}
});
});
}
/**
* Stop impersonating the EPerson
*/
stopImpersonating() {
this.authService.stopImpersonatingAndRefresh();
this.isImpersonated = false;
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
ngOnDestroy(): void {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id);
if (hasValue(this.emailValueChangeSubscribe)) {
this.emailValueChangeSubscribe.unsubscribe();
}
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
/** /**
* Checks for the given ePerson if there is already an ePerson in the system with that email * Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case * and shows notification if this is the case
@@ -416,17 +523,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
})); }));
} }
/**
* Event triggered when the user changes page
* @param event
*/
onPageChange(event) {
this.updateGroups({
currentPage: event,
elementsPerPage: this.config.pageSize
});
}
/** /**
* Update the list of groups by fetching it from the rest api or cache * Update the list of groups by fetching it from the rest api or cache
*/ */
@@ -435,71 +531,4 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
})); }));
} }
/**
* Start impersonating the EPerson
*/
impersonate() {
this.authService.impersonate(this.epersonInitial.id);
this.isImpersonated = true;
}
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.brandColor = 'danger';
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.submitForm.emit();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
}
this.cancelForm.emit();
});
}}
});
});
}
/**
* Stop impersonating the EPerson
*/
stopImpersonating() {
this.authService.stopImpersonatingAndRefresh();
this.isImpersonated = false;
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
ngOnDestroy(): void {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id);
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
} }

View File

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

View File

@@ -14,9 +14,9 @@ import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf, of as observableOf,
Subscription Subscription,
} from 'rxjs'; } from 'rxjs';
import { catchError, map, switchMap, take } from 'rxjs/operators'; import { catchError, map, switchMap, take, filter } from 'rxjs/operators';
import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
@@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { import {
getRemoteDataPayload, getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstCompletedRemoteData getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/aletr-type'; import { AlertType } from '../../../shared/alert/aletr-type';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
@@ -65,6 +66,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* Dynamic models for the inputs of form * Dynamic models for the inputs of form
*/ */
groupName: DynamicInputModel; groupName: DynamicInputModel;
groupCommunity: DynamicInputModel;
groupDescription: DynamicTextAreaModel; groupDescription: DynamicTextAreaModel;
/** /**
@@ -125,16 +127,16 @@ export class GroupFormComponent implements OnInit, OnDestroy {
public AlertTypeEnum = AlertType; public AlertTypeEnum = AlertType;
constructor(public groupDataService: GroupDataService, constructor(public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService, private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService, private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService, private formBuilderService: FormBuilderService,
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private route: ActivatedRoute, private route: ActivatedRoute,
protected router: Router, protected router: Router,
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private modalService: NgbModal, private modalService: NgbModal,
public requestService: RequestService) { public requestService: RequestService) {
} }
ngOnInit() { ngOnInit() {
@@ -160,8 +162,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
); );
observableCombineLatest( observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.groupName`), this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`) this.translateService.get(`${this.messagePrefix}.groupDescription`)
).subscribe(([groupName, groupDescription]) => { ).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({ this.groupName = new DynamicInputModel({
id: 'groupName', id: 'groupName',
label: groupName, label: groupName,
@@ -171,6 +174,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
}, },
required: true, required: true,
}); });
this.groupCommunity = new DynamicInputModel({
id: 'groupCommunity',
label: groupCommunity,
name: 'groupCommunity',
required: false,
readOnly: true,
});
this.groupDescription = new DynamicTextAreaModel({ this.groupDescription = new DynamicTextAreaModel({
id: 'groupDescription', id: 'groupDescription',
label: groupDescription, label: groupDescription,
@@ -185,17 +195,36 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.subs.push( this.subs.push(
observableCombineLatest( observableCombineLatest(
this.groupDataService.getActiveGroup(), this.groupDataService.getActiveGroup(),
this.canEdit$ this.canEdit$,
).subscribe(([activeGroup, canEdit]) => { this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) { if (activeGroup != null) {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
this.formGroup.patchValue({
groupName: activeGroup != null ? activeGroup.name : '', if (linkedObject?.name) {
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '', this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
}); this.formGroup.patchValue({
if (!canEdit || activeGroup.permanent) { groupName: activeGroup.name,
this.formGroup.disable(); groupCommunity: linkedObject?.name ?? '',
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
} else {
this.formModel = [
this.groupName,
this.groupDescription,
];
this.formGroup.patchValue({
groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
} }
setTimeout(() => {
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
}
}, 200);
} }
}) })
); );
@@ -417,11 +446,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (hasValue(group) && hasValue(group._links.object.href)) { if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe( return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => { map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) { return hasValue(rd) && hasValue(rd.payload);
return true;
} else {
return false;
}
}), }),
catchError(() => observableOf(false)), catchError(() => observableOf(false)),
); );

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@
<tr> <tr>
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th> <th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th> <th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th> <th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
<th>{{messagePrefix + 'table.edit' | translate}}</th> <th>{{messagePrefix + 'table.edit' | translate}}</th>
</tr> </tr>
@@ -56,6 +57,7 @@
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page"> <tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td> <td>{{groupDto.group.id}}</td>
<td>{{groupDto.group.name}}</td> <td>{{groupDto.group.name}}</td>
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td> <td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">

View File

@@ -152,6 +152,7 @@ describe('GroupRegistryComponent', () => {
return createSuccessfulRemoteDataObject$(undefined); return createSuccessfulRemoteDataObject$(undefined);
} }
}; };
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
setIsAuthorized(true, true); setIsAuthorized(true, true);
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
@@ -200,6 +201,13 @@ describe('GroupRegistryComponent', () => {
}); });
}); });
it('should display community/collection name if present', () => {
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
expect(collectionNamesFound.length).toEqual(2);
expect(collectionNamesFound[0].nativeElement.textContent).toEqual('');
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
});
describe('edit buttons', () => { describe('edit buttons', () => {
describe('when the user is a general admin', () => { describe('when the user is a general admin', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
@@ -213,7 +221,7 @@ describe('GroupRegistryComponent', () => {
})); }));
it('should be active', () => { it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse(); expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -247,7 +255,7 @@ describe('GroupRegistryComponent', () => {
})); }));
it('should be active', () => { it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse(); expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -266,7 +274,7 @@ describe('GroupRegistryComponent', () => {
})); }));
it('should not be active', () => { it('should not be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeTrue(); expect(editButtonFound.nativeElement.disabled).toBeTrue();

View File

@@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { NoContent } from '../../core/shared/NoContent.model'; import { NoContent } from '../../core/shared/NoContent.model';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
@Component({ @Component({
selector: 'ds-groups-registry', selector: 'ds-groups-registry',
@@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
} }
return this.groupService.searchGroups(this.currentSearchQuery.trim(), { return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize elementsPerPage: paginationOptions.pageSize,
}); }, true, true, followLink('object'));
}), }),
getAllSucceededRemoteData(), getAllSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),

View File

@@ -1,4 +1,4 @@
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -9,7 +9,13 @@ import {
Optional, Optional,
PLATFORM_ID, PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; import {
ActivatedRouteSnapshot,
NavigationCancel,
NavigationEnd,
NavigationStart, ResolveEnd,
Router,
} from '@angular/router';
import { BehaviorSubject, Observable, of } from 'rxjs'; import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
*/ */
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false); isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/** /**
* Whether or not the idle modal is is currently open * Whether or not the idle modal is is currently open
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.themeService.getThemeName$().subscribe((themeName: string) => { this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser // the theme css will never download server side, so this should only happen on the browser
this.isThemeLoading$.next(true); this.isThemeCSSLoading$.next(true);
} }
if (hasValue(themeName)) { if (hasValue(themeName)) {
this.setThemeCss(themeName); this.setThemeCss(themeName);
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
ngAfterViewInit() { ngAfterViewInit() {
this.router.events.pipe( let resolveEndFound = false;
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component this.router.events.subscribe((event) => {
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
delay(0)
).subscribe((event) => {
if (event instanceof NavigationStart) { if (event instanceof NavigationStart) {
resolveEndFound = false;
this.isRouteLoading$.next(true); this.isRouteLoading$.next(true);
this.isThemeLoading$.next(true);
} else if (event instanceof ResolveEnd) {
resolveEndFound = true;
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
switchMap((changed) => {
if (changed) {
return this.isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
this.isThemeLoading$.next(changed);
});
} else if ( } else if (
event instanceof NavigationEnd || event instanceof NavigationEnd ||
event instanceof NavigationCancel event instanceof NavigationCancel
) { ) {
if (!resolveEndFound) {
this.isThemeLoading$.next(false);
}
this.isRouteLoading$.next(false); this.isRouteLoading$.next(false);
} }
}); });
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}); });
} }
// the fact that this callback is used, proves we're on the browser. // the fact that this callback is used, proves we're on the browser.
this.isThemeLoading$.next(false); this.isThemeCSSLoading$.next(false);
}; };
head.appendChild(link); head.appendChild(link);
} }

View File

@@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import {
DYNAMIC_ERROR_MESSAGES_MATCHER,
DYNAMIC_MATCHER_PROVIDERS,
DynamicErrorMessagesMatcher
} from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
@@ -52,6 +56,7 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { UUIDService } from './core/shared/uuid.service'; import { UUIDService } from './core/shared/uuid.service';
import { CookieService } from './core/services/cookie.service'; import { CookieService } from './core/services/cookie.service';
import { AbstractControl } from '@angular/forms';
export function getBase() { export function getBase() {
return environment.ui.nameSpace; return environment.ui.nameSpace;
@@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer<AppState>[] {
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
} }
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
SharedModule, SharedModule,
@@ -146,6 +159,10 @@ const PROVIDERS = [
multi: true, multi: true,
deps: [ CookieService, UUIDService ] deps: [ CookieService, UUIDService ]
}, },
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
...DYNAMIC_MATCHER_PROVIDERS, ...DYNAMIC_MATCHER_PROVIDERS,
]; ];

View File

@@ -0,0 +1,54 @@
<div *ngVar="(contentSource$ |async) as contentSource">
<div class="container-fluid" *ngIf="shouldShow">
<h4>{{ 'collection.source.controls.head' | translate }}</h4>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span>
<span>{{contentSource?.harvestStatus}}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.start' | translate}}</span>
<span>{{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.last' | translate}}</span>
<span>{{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.message' | translate}}</span>
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<button *ngIf="!(testConfigRunning$ |async)" class="btn btn-secondary"
[disabled]="!(isEnabled)"
(click)="testConfiguration(contentSource)">
<span>{{'collection.source.controls.test.submit' | translate}}</span>
</button>
<button *ngIf="(testConfigRunning$ |async)" class="btn btn-secondary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span>{{'collection.source.controls.test.running' | translate}}</span>
</button>
<button *ngIf="!(importRunning$ |async)" class="btn btn-primary"
[disabled]="!(isEnabled)"
(click)="importNow()">
<span class="d-none d-sm-inline">{{'collection.source.controls.import.submit' | translate}}</span>
</button>
<button *ngIf="(importRunning$ |async)" class="btn btn-primary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span class="d-none d-sm-inline">{{'collection.source.controls.import.running' | translate}}</span>
</button>
<button *ngIf="!(reImportRunning$ |async)" class="btn btn-primary"
[disabled]="!(isEnabled)"
(click)="resetAndReimport()">
<span class="d-none d-sm-inline">&nbsp;{{'collection.source.controls.reset.submit' | translate}}</span>
</button>
<button *ngIf="(reImportRunning$ |async)" class="btn btn-primary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span class="d-none d-sm-inline">&nbsp;{{'collection.source.controls.reset.running' | translate}}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.spinner-button {
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
}

View File

@@ -0,0 +1,232 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ContentSource } from '../../../../core/shared/content-source.model';
import { Collection } from '../../../../core/shared/collection.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { CollectionDataService } from '../../../../core/data/collection-data.service';
import { RequestService } from '../../../../core/data/request.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { HttpClient } from '@angular/common/http';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { Process } from '../../../../process-page/processes/process.model';
import { of as observableOf } from 'rxjs';
import { CollectionSourceControlsComponent } from './collection-source-controls.component';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { By } from '@angular/platform-browser';
import { VarDirective } from '../../../../shared/utils/var.directive';
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
describe('CollectionSourceControlsComponent', () => {
let comp: CollectionSourceControlsComponent;
let fixture: ComponentFixture<CollectionSourceControlsComponent>;
const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4';
let contentSource: ContentSource;
let collection: Collection;
let process: Process;
let bitstream: Bitstream;
let scriptDataService: ScriptDataService;
let processDataService: ProcessDataService;
let requestService: RequestService;
let notificationsService;
let collectionService: CollectionDataService;
let httpClient: HttpClient;
let bitstreamService: BitstreamDataService;
let scheduler: TestScheduler;
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
contentSource = Object.assign(new ContentSource(), {
uuid: uuid,
metadataConfigs: [
{
id: 'dc',
label: 'Simple Dublin Core',
nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/'
},
{
id: 'qdc',
label: 'Qualified Dublin Core',
nameSpace: 'http://purl.org/dc/terms/'
},
{
id: 'dim',
label: 'DSpace Intermediate Metadata',
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
}
],
oaiSource: 'oai-harvest-source',
oaiSetId: 'oai-set-id',
_links: {self: {href: 'contentsource-selflink'}}
});
process = Object.assign(new Process(), {
processId: 'process-id', processStatus: 'COMPLETED',
_links: {output: {href: 'output-href'}}
});
bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}});
collection = Object.assign(new Collection(), {
uuid: 'fake-collection-id',
_links: {self: {href: 'collection-selflink'}}
});
notificationsService = new NotificationsServiceStub();
collectionService = jasmine.createSpyObj('collectionService', {
getContentSource: createSuccessfulRemoteDataObject$(contentSource),
findByHref: createSuccessfulRemoteDataObject$(collection)
});
scriptDataService = jasmine.createSpyObj('scriptDataService', {
invoke: createSuccessfulRemoteDataObject$(process),
});
processDataService = jasmine.createSpyObj('processDataService', {
findById: createSuccessfulRemoteDataObject$(process),
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findByHref: createSuccessfulRemoteDataObject$(bitstream),
});
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf('Script text'),
});
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [CollectionSourceControlsComponent, VarDirective],
providers: [
{provide: ScriptDataService, useValue: scriptDataService},
{provide: ProcessDataService, useValue: processDataService},
{provide: RequestService, useValue: requestService},
{provide: NotificationsService, useValue: notificationsService},
{provide: CollectionDataService, useValue: collectionService},
{provide: HttpClient, useValue: httpClient},
{provide: BitstreamDataService, useValue: bitstreamService}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionSourceControlsComponent);
comp = fixture.componentInstance;
comp.isEnabled = true;
comp.collection = collection;
comp.shouldShow = true;
fixture.detectChanges();
});
describe('init', () => {
it('should', () => {
expect(comp).toBeTruthy();
});
});
describe('testConfiguration', () => {
it('should invoke a script and ping the resulting process until completed and show the resulting info', () => {
comp.testConfiguration(contentSource);
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-g', value: null},
{name: '-a', value: contentSource.oaiSource},
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
});
});
describe('importNow', () => {
it('should invoke a script that will start the harvest', () => {
comp.importNow();
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-r', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(notificationsService.success).toHaveBeenCalled();
});
});
describe('resetAndReimport', () => {
it('should invoke a script that will start the harvest', () => {
comp.resetAndReimport();
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-o', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(notificationsService.success).toHaveBeenCalled();
});
});
describe('the controls', () => {
it('should be shown when shouldShow is true', () => {
comp.shouldShow = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons.length).toEqual(3);
});
it('should be shown when shouldShow is false', () => {
comp.shouldShow = false;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons.length).toEqual(0);
});
it('should be disabled when isEnabled is false', () => {
comp.shouldShow = true;
comp.isEnabled = false;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons[0].nativeElement.disabled).toBeTrue();
expect(buttons[1].nativeElement.disabled).toBeTrue();
expect(buttons[2].nativeElement.disabled).toBeTrue();
});
it('should be enabled when isEnabled is true', () => {
comp.shouldShow = true;
comp.isEnabled = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons[0].nativeElement.disabled).toBeFalse();
expect(buttons[1].nativeElement.disabled).toBeFalse();
expect(buttons[2].nativeElement.disabled).toBeFalse();
});
it('should call the corresponding button when clicked', () => {
spyOn(comp, 'testConfiguration');
spyOn(comp, 'importNow');
spyOn(comp, 'resetAndReimport');
comp.shouldShow = true;
comp.isEnabled = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
buttons[0].triggerEventHandler('click', null);
expect(comp.testConfiguration).toHaveBeenCalled();
buttons[1].triggerEventHandler('click', null);
expect(comp.importNow).toHaveBeenCalled();
buttons[2].triggerEventHandler('click', null);
expect(comp.resetAndReimport).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,233 @@
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 { Subscription } from 'rxjs/internal/Subscription';
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 { Observable } from 'rxjs/internal/Observable';
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';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
/**
* Component that contains the controls to run, reset and test the harvest
*/
@Component({
selector: 'ds-collection-source-controls',
styleUrls: ['./collection-source-controls.component.scss'],
templateUrl: './collection-source-controls.component.html',
})
export class CollectionSourceControlsComponent implements OnDestroy {
/**
* Should the controls be enabled.
*/
@Input() isEnabled: boolean;
/**
* The current collection
*/
@Input() collection: Collection;
/**
* Should the control section be shown
*/
@Input() shouldShow: boolean;
contentSource$: Observable<ContentSource>;
private subs: Subscription[] = [];
testConfigRunning$ = new BehaviorSubject(false);
importRunning$ = new BehaviorSubject(false);
reImportRunning$ = new BehaviorSubject(false);
constructor(private scriptDataService: ScriptDataService,
private processDataService: ProcessDataService,
private requestService: RequestService,
private notificationsService: NotificationsService,
private collectionService: CollectionDataService,
private translateService: TranslateService,
private httpClient: HttpClient,
private bitstreamService: BitstreamDataService
) {
}
ngOnInit() {
// ensure the contentSource gets updated after being set to stale
this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe(
getAllSucceededRemoteDataPayload(),
switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)),
getAllSucceededRemoteDataPayload()
);
}
/**
* Tests the provided content source's configuration.
* @param contentSource - The content source to be tested
*/
testConfiguration(contentSource) {
this.testConfigRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-g', value: null},
{name: '-a', value: contentSource.oaiSource},
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
], []).pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
// show a notification when the script invocation fails
this.notificationsService.error(this.translateService.get('collection.source.controls.test.submit.error'));
this.testConfigRunning$.next(false);
}
}),
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process: Process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
this.testConfigRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
.replaceAll('The script has started', '')
.replaceAll('The script has completed', '');
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
});
});
this.testConfigRunning$.next(false);
}
}
));
}
/**
* Start the harvest for the current collection
*/
importNow() {
this.importRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-r', value: null},
{name: '-c', value: this.collection.uuid},
], [])
.pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.submit.error'));
this.importRunning$.next(false);
} else {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.submit.success'));
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
this.importRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.importRunning$.next(false);
}
}
));
}
/**
* Reset and reimport the current collection
*/
resetAndReimport() {
this.reImportRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-o', value: null},
{name: '-c', value: this.collection.uuid},
], [])
.pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.submit.error'));
this.reImportRunning$.next(false);
} else {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.submit.success'));
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
this.reImportRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.reImportRunning$.next(false);
}
}
));
}
ngOnDestroy(): void {
this.subs.forEach((sub) => {
if (hasValue(sub)) {
sub.unsubscribe();
}
});
}
}

View File

@@ -1,57 +1,74 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="d-inline-block float-right"> <div class="d-inline-block float-right">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)" <button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)" [disabled]="!(hasChanges() | async)"
(click)="discard()"><i (click)="discard()"><i
class="fas fa-times"></i> class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button> </button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async" <button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i (click)="reinstate()"><i
class="fas fa-undo-alt"></i> class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button> </button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)" <button class="btn btn-primary"
(click)="onSubmit()"><i [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
class="fas fa-save"></i> (click)="onSubmit()"><i
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> class="fas fa-save"></i>
</button> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</div> </button>
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4> </div>
<div *ngIf="contentSource" class="form-check mb-4"> <h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()"> <div *ngIf="contentSource" class="form-check mb-4">
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label> <input type="checkbox" class="form-check-input" id="externalSourceCheck"
</div> [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading> <label class="form-check-label"
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4> for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div>
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
</div> </div>
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)" <div class="row">
[formId]="'collection-source-form-id'" <ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
[formGroup]="formGroup" [formId]="'collection-source-form-id'"
[formModel]="formModel" [formGroup]="formGroup"
[formLayout]="formLayout" [formModel]="formModel"
[displaySubmit]="false" [formLayout]="formLayout"
[displayCancel]="false" [displaySubmit]="false"
(dfChange)="onChange($event)" [displayCancel]="false"
(submitForm)="onSubmit()" (dfChange)="onChange($event)"
(cancel)="onCancel()"></ds-form> (submitForm)="onSubmit()"
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)"> (cancel)="onCancel()"></ds-form>
<div class="d-inline-block float-right">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div> </div>
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
<div class="row">
<div class="col-12">
<div class="d-inline-block float-right ml-1">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary"
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div>
</div>
</div>
<ds-collection-source-controls
[isEnabled]="!(hasChanges()|async)"
[shouldShow]="contentSource?.harvestType !== harvestTypeNone"
[collection]="(collectionRD$ |async)?.payload"
>
</ds-collection-source-controls>

View File

@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
label: 'DSpace Intermediate Metadata', label: 'DSpace Intermediate Metadata',
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
} }
] ],
_links: { self: { href: 'contentsource-selflink' } }
}); });
fieldUpdate = { fieldUpdate = {
field: contentSource, field: contentSource,
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
updateContentSource: observableOf(contentSource), updateContentSource: observableOf(contentSource),
getHarvesterEndpoint: observableOf('harvester-endpoint') getHarvesterEndpoint: observableOf('harvester-endpoint')
}); });
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule], imports: [TranslateModule.forRoot(), RouterTestingModule],

View File

@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
take(1) take(1)
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
// Update harvester // Update harvester
this.collectionRD$.pipe( this.collectionRD$.pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),

View File

@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
import { CollectionSourceComponent } from './collection-source/collection-source.component'; import { CollectionSourceComponent } from './collection-source/collection-source.component';
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
import { CollectionFormModule } from '../collection-form/collection-form.module'; import { CollectionFormModule } from '../collection-form/collection-form.module';
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
/** /**
* Module that contains all components related to the Edit Collection page administrator functionality * Module that contains all components related to the Edit Collection page administrator functionality
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
CollectionRolesComponent, CollectionRolesComponent,
CollectionCurateComponent, CollectionCurateComponent,
CollectionSourceComponent, CollectionSourceComponent,
CollectionSourceControlsComponent,
CollectionAuthorizationsComponent CollectionAuthorizationsComponent
] ]
}) })

View File

@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
* Get the collection's content harvester * Get the collection's content harvester
* @param collectionId * @param collectionId
*/ */
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> { getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
const href$ = this.getHarvesterEndpoint(collectionId).pipe( const href$ = this.getHarvesterEndpoint(collectionId).pipe(
isNotEmptyOperator(), isNotEmptyOperator(),
take(1) take(1)
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
href$.subscribe((href: string) => { href$.subscribe((href: string) => {
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href); const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
this.requestService.send(request, true); this.requestService.send(request, useCachedVersionIfAvailable);
}); });
return this.rdbService.buildSingle<ContentSource>(href$); return this.rdbService.buildSingle<ContentSource>(href$);
@@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
} }
/** /**
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item * Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
* @param item Item we want the owning collection of * @param item Item we want the owning collection of
*/ */
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> { findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
return this.findByHref(item._links.owningCollection.href); return this.findByHref(item._links.owningCollection.href);
} }
/**
* Get a list of mapped collections for the given item.
* @param item Item for which the mapped collections should be retrieved.
* @param findListOptions Pagination and search options.
*/
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
}
} }

View File

@@ -56,11 +56,11 @@ describe('EPersonDataService', () => {
} }
function init() { function init() {
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; restEndpointURL = 'https://rest.api/dspace-spring-rest/api/eperson';
epersonsEndpoint = `${restEndpointURL}/epersons`; epersonsEndpoint = `${restEndpointURL}/epersons`;
epeople = [EPersonMock, EPersonMock2]; epeople = [EPersonMock, EPersonMock2];
epeople$ = createSuccessfulRemoteDataObject$(createPaginatedList([epeople])); 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); halService = new HALEndpointServiceStub(restEndpointURL);
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@@ -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');
});
});
});

View File

@@ -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;
}
}

View File

@@ -1,4 +1,4 @@
import { autoserializeAs, deserializeAs, deserialize } from 'cerialize'; import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize';
import { HALLink } from './hal-link.model'; import { HALLink } from './hal-link.model';
import { MetadataConfig } from './metadata-config.model'; import { MetadataConfig } from './metadata-config.model';
import { CacheableObject } from '../cache/object-cache.reducer'; 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 { CONTENT_SOURCE } from './content-source.resource-type';
import { excludeFromEquals } from '../utilities/equals.decorators'; import { excludeFromEquals } from '../utilities/equals.decorators';
import { ResourceType } from './resource-type'; import { ResourceType } from './resource-type';
import { ContentSourceSetSerializer } from './content-source-set-serializer';
/** /**
* The type of content harvesting used * The type of content harvesting used
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
/** /**
* OAI Specific set ID * OAI Specific set ID
*/ */
@autoserializeAs('oai_set_id') @deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
oaiSetId: string; oaiSetId: string;
/** /**
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
*/ */
metadataConfigs: MetadataConfig[]; 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 * The {@link HALLink}s for this ContentSource
*/ */

View File

@@ -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 { Injectable, OnDestroy } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { map, switchMap, take } from 'rxjs/operators'; 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 { LinkService } from '../../cache/builders/link.service';
import { PaginatedList } from '../../data/paginated-list.model'; import { PaginatedList } from '../../data/paginated-list.model';
import { ResponseParsingService } from '../../data/parsing.service'; import { ResponseParsingService } from '../../data/parsing.service';
@@ -13,7 +13,7 @@ import { DSpaceObject } from '../dspace-object.model';
import { GenericConstructor } from '../generic-constructor'; import { GenericConstructor } from '../generic-constructor';
import { HALEndpointService } from '../hal-endpoint.service'; import { HALEndpointService } from '../hal-endpoint.service';
import { URLCombiner } from '../../url-combiner/url-combiner'; 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 { SearchOptions } from '../../../shared/search/search-options.model';
import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model';
import { SearchResponseParsingService } from '../../data/search-response-parsing.service'; 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 { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service';
import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service'; import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { Community } from '../community.model';
import { CommunityDataService } from '../../data/community-data.service'; import { CommunityDataService } from '../../data/community-data.service';
import { ViewMode } from '../view-mode.model'; import { ViewMode } from '../view-mode.model';
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators';
getFirstSucceededRemoteData,
getFirstCompletedRemoteData,
getRemoteDataPayload
} from '../operators';
import { RouteService } from '../../services/route.service'; import { RouteService } from '../../services/route.service';
import { SearchResult } from '../../../shared/search/search-result.model'; import { SearchResult } from '../../../shared/search/search-result.model';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.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); 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<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
*/
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
if (isEmpty(scopeId)) {
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
getFirstSucceededRemoteData(),
map(
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
)
);
return top;
}
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getFirstSucceededRemoteData());
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
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 * Requests the current view mode based on the current URL
* @returns {Observable<ViewMode>} The current view mode * @returns {Observable<ViewMode>} The current view mode

View File

@@ -21,6 +21,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RouterStub } from '../../../shared/testing/router.stub'; import { RouterStub } from '../../../shared/testing/router.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub'; import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
import { getTestScheduler } from 'jasmine-marbles';
describe('UploadBistreamComponent', () => { describe('UploadBistreamComponent', () => {
let comp: UploadBitstreamComponent; let comp: UploadBitstreamComponent;
@@ -76,7 +77,8 @@ describe('UploadBistreamComponent', () => {
const restEndpoint = 'fake-rest-endpoint'; const restEndpoint = 'fake-rest-endpoint';
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', { const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
getBitstreamsEndpoint: observableOf(restEndpoint), getBitstreamsEndpoint: observableOf(restEndpoint),
createBundle: createSuccessfulRemoteDataObject$(createdBundle) createBundle: createSuccessfulRemoteDataObject$(createdBundle),
getBundles: createSuccessfulRemoteDataObject$([bundle])
}); });
const bundleService = jasmine.createSpyObj('bundleService', { const bundleService = jasmine.createSpyObj('bundleService', {
getBitstreamsEndpoint: observableOf(restEndpoint), getBitstreamsEndpoint: observableOf(restEndpoint),
@@ -92,6 +94,22 @@ describe('UploadBistreamComponent', () => {
removeByHrefSubstring: {} 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', () => { describe('when a file is uploaded', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
createUploadBitstreamTestingModule({}); createUploadBitstreamTestingModule({});

View File

@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model'; 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 { ActivatedRoute, Router } from '@angular/router';
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; 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.itemId = this.route.snapshot.params.id;
this.entityType = this.route.snapshot.params['entity-type']; this.entityType = this.route.snapshot.params['entity-type'];
this.itemRD$ = this.route.data.pipe(map((data) => data.dso)); this.itemRD$ = this.route.data.pipe(map((data) => data.dso));
this.bundlesRD$ = this.itemRD$.pipe( this.bundlesRD$ = this.itemService.getBundles(this.itemId);
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
);
this.selectedBundleId = this.route.snapshot.queryParams.bundle; this.selectedBundleId = this.route.snapshot.queryParams.bundle;
if (isNotEmpty(this.selectedBundleId)) { if (isNotEmpty(this.selectedBundleId)) {
this.bundleService.findById(this.selectedBundleId).pipe( this.bundleService.findById(this.selectedBundleId).pipe(

View File

@@ -1,10 +1,10 @@
<td> <td>
<div class="metadata-field"> <div class="metadata-field">
<div *ngIf="!(editable | async)"> <div *ngIf="!(editable | async)">
<span>{{metadata?.key?.split('.').join('.&#8203;')}}</span> <span >{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div> </div>
<div *ngIf="(editable | async)" class="field-container"> <div *ngIf="(editable | async)" class="field-container">
<ds-validation-suggestions [suggestions]="(metadataFieldSuggestions | async)" <ds-validation-suggestions [disable]="fieldUpdate.changeType != 1" [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key" [(ngModel)]="metadata.key"
[url]="this.url" [url]="this.url"
[metadata]="this.metadata" [metadata]="this.metadata"

View File

@@ -463,4 +463,43 @@ describe('EditInPlaceFieldComponent', () => {
}); });
}); });
describe('canEditMetadataField', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(false);
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
describe('when the fieldUpdate\'s changeType is currently UPDATE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
});
}); });

View File

@@ -1,7 +1,21 @@
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<div class="collections"> <div class="collections">
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]"> <a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span> <span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a> </a>
</div> </div>
<div *ngIf="isLoading$ | async">
{{'item.page.collections.loading' | translate}}
</div>
<a
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
(click)="$event.preventDefault(); handleLoadMore()"
class="load-more-btn btn btn-sm btn-outline-secondary"
role="button"
href="#"
>
{{'item.page.collections.load-more' | translate}}
</a>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>

View File

@@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { CollectionsComponent } from './collections.component'; 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; const createMockCollection = (id: string) => Object.assign(new Collection(), {
let fixture: ComponentFixture<CollectionsComponent>; id: id,
name: `collection-${id}`,
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 succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)}); const mockItem: Item = new Item();
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
describe('CollectionsComponent', () => { describe('CollectionsComponent', () => {
collectionDataServiceStub = { let collectionDataService;
findOwningCollectionFor(item: Item) {
if (item === succeededMockItem) { let mockCollection1: Collection;
return createSuccessfulRemoteDataObject$(mockCollection1); let mockCollection2: Collection;
} else { let mockCollection3: Collection;
return createFailedRemoteDataObject$('error', 500); let mockCollection4: Collection;
}
} let component: CollectionsComponent;
}; let fixture: ComponentFixture<CollectionsComponent>;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
collectionDataService = jasmine.createSpyObj([
'findOwningCollectionFor',
'findMappedCollectionsFor',
]);
mockCollection1 = createMockCollection('c1');
mockCollection2 = createMockCollection('c2');
mockCollection3 = createMockCollection('c3');
mockCollection4 = createMockCollection('c4');
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [ CollectionsComponent ], declarations: [ CollectionsComponent ],
providers: [ providers: [
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}, { provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
{ provide: CollectionDataService, useValue: collectionDataServiceStub }, { provide: CollectionDataService, useValue: collectionDataService },
], ],
schemas: [ NO_ERRORS_SCHEMA ] schemas: [ NO_ERRORS_SCHEMA ]
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(CollectionsComponent); fixture = TestBed.createComponent(CollectionsComponent);
collectionsComponent = fixture.componentInstance; component = fixture.componentInstance;
collectionsComponent.label = 'test.test'; component.item = mockItem;
collectionsComponent.separator = '<br/>'; component.label = 'test.test';
component.separator = '<br/>';
component.pageSize = 2;
})); }));
describe('When the requested item request has succeeded', () => { describe('when the item has only an owning collection', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => { 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(); fixture.detectChanges();
}); });
it('should show the collection', () => { it('should display the owning collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
expect(collectionField).not.toBeNull(); 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<Collection>;
beforeEach(() => { 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(); fixture.detectChanges();
}); });
it('should not show the collection', () => { it('should display the owning collection and the mapped collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
expect(collectionField).toBeNull(); 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<Collection>;
let mockPage2: PaginatedList<Collection>;
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<Collection>;
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();
});
});
}); });

View File

@@ -1,14 +1,19 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
import { CollectionDataService } from '../../../core/data/collection-data.service'; import { CollectionDataService } from '../../../core/data/collection-data.service';
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { hasValue } from '../../../shared/empty.util'; 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 * This component renders the parent collections section of the item
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
separator = '<br/>'; separator = '<br/>';
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>; /**
* Amount of mapped collections that should be fetched at once.
*/
pageSize = 5;
/**
* Last page of the mapped collections that has been fetched.
*/
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(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<void> = new BehaviorSubject(undefined);
/**
* Whether or not a page of mapped collections is currently being loaded.
*/
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* Whether or not more pages of mapped collections are available.
*/
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(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<Collection[]>;
constructor(private cds: CollectionDataService) { constructor(private cds: CollectionDataService) {
} }
ngOnInit(): void { ngOnInit(): void {
// this.collections = this.item.parents.payload; const owningCollection$: Observable<Collection> = this.cds.findOwningCollectionFor(this.item).pipe(
getFirstSucceededRemoteDataPayload(),
startWith(null as Collection),
);
// TODO: this should use parents, but the collections const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
// for an Item aren't returned by the REST API yet, // update isLoading$
// only the owning collection tap(() => this.isLoading$.next(true)),
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
map((rd: RemoteData<Collection>) => { // request next batch of mapped collections
if (hasValue(rd.payload)) { withLatestFrom(this.lastPage$),
return new RemoteData( switchMap(([_, lastPage]: [void, number]) => {
rd.timeCompleted, return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
rd.msToLive, elementsPerPage: this.pageSize,
rd.lastUpdated, currentPage: lastPage + 1,
rd.state, }));
rd.errorMessage, }),
buildPaginatedList({
elementsPerPage: 10, getAllCompletedRemoteData<PaginatedList<Collection>>(),
totalPages: 1,
currentPage: 1, // update isLoading$
totalElements: 1, tap(() => this.isLoading$.next(false)),
_links: {
self: rd.payload._links.self getAllSucceededRemoteDataPayload(),
}
} as PageInfo, [rd.payload]), // update hasMore$
rd.statusCode tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
);
} else { // update lastPage$
return rd as any; tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
}
}) getPaginatedListPayload<Collection>(),
// add current batch to list of collections
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
startWith([]),
) as Observable<Collection[]>;
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
}),
); );
} }
handleLoadMore() {
this.loadMore$.next();
}
} }

View File

@@ -33,6 +33,7 @@ import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/med
import { NgxGalleryModule } from '@kolkov/ngx-gallery'; import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { VersionPageComponent } from './version-page/version-page/version-page.component'; import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.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 = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -41,6 +42,7 @@ const ENTRY_COMPONENTS = [
]; ];
const DECLARATIONS = [ const DECLARATIONS = [
ThemedFileSectionComponent,
ItemPageComponent, ItemPageComponent,
ThemedItemPageComponent, ThemedItemPageComponent,
FullItemPageComponent, FullItemPageComponent,

View File

@@ -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<FileSectionComponent> {
@Input() item: Item;
protected inAndOutputNames: (keyof FileSectionComponent & keyof this)[] = ['item'];
protected getComponentName(): string {
return 'FileSectionComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/item-page/simple/field-components/file-section/file-section.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./file-section.component`);
}
}

View File

@@ -16,7 +16,7 @@
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer> <ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container> </ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section> <ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field> <ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field" <ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object" [parentItem]="object"

View File

@@ -19,7 +19,7 @@
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer> <ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container> </ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section> <ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field> <ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field" <ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object" [parentItem]="object"

View File

@@ -15,7 +15,7 @@
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope" [scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()" [currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)" [showScopeSelector]="true"
[inPlaceSearch]="inPlaceSearch" [inPlaceSearch]="inPlaceSearch"
[searchPlaceholder]="'mydspace.search-form.placeholder' | translate"> [searchPlaceholder]="'mydspace.search-form.placeholder' | translate">
</ds-search-form> </ds-search-form>

View File

@@ -78,11 +78,6 @@ export class MyDSpacePageComponent implements OnInit {
*/ */
sortOptions$: Observable<SortOptions[]>; sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/** /**
* Emits true if were on a small screen * Emits true if were on a small screen
*/ */
@@ -144,10 +139,6 @@ export class MyDSpacePageComponent implements OnInit {
this.resultsRD$.next(results); this.resultsRD$.next(results);
}); });
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
this.context$ = this.searchConfigService.getCurrentConfiguration('workspace') this.context$ = this.searchConfigService.getCurrentConfiguration('workspace')
.pipe( .pipe(
map((configuration: string) => { map((configuration: string) => {

View File

@@ -1,5 +1,8 @@
<div class="nav-item dropdown expandable-navbar-section" <div class="nav-item dropdown expandable-navbar-section"
(keyup.enter)="activateSection($event)" *ngVar="(active | async) as isActive"
(keyup.enter)="isActive ? deactivateSection($event) : activateSection($event)"
(keyup.space)="isActive ? deactivateSection($event) : activateSection($event)"
(keydown.space)="$event.preventDefault()"
(mouseenter)="activateSection($event)" (mouseenter)="activateSection($event)"
(mouseleave)="deactivateSection($event)"> (mouseleave)="deactivateSection($event)">
<a href="#" class="nav-link dropdown-toggle" routerLinkActive="active" <a href="#" class="nav-link dropdown-toggle" routerLinkActive="active"

View File

@@ -9,6 +9,7 @@ import { HostWindowService } from '../../shared/host-window.service';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { VarDirective } from '../../shared/utils/var.directive';
describe('ExpandableNavbarSectionComponent', () => { describe('ExpandableNavbarSectionComponent', () => {
let component: ExpandableNavbarSectionComponent; let component: ExpandableNavbarSectionComponent;
@@ -19,7 +20,7 @@ describe('ExpandableNavbarSectionComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [NoopAnimationsModule], imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent], declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
providers: [ providers: [
{ provide: 'sectionDataProvider', useValue: {} }, { provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
@@ -76,6 +77,78 @@ 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', () => { describe('when a click occurs on the section header', () => {
beforeEach(() => { beforeEach(() => {
spyOn(menuService, 'toggleActiveSection'); spyOn(menuService, 'toggleActiveSection');
@@ -96,7 +169,7 @@ describe('ExpandableNavbarSectionComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [NoopAnimationsModule], imports: [NoopAnimationsModule],
declarations: [ExpandableNavbarSectionComponent, TestComponent], declarations: [ExpandableNavbarSectionComponent, TestComponent, VarDirective],
providers: [ providers: [
{ provide: 'sectionDataProvider', useValue: {} }, { provide: 'sectionDataProvider', useValue: {} },
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },

View File

@@ -14,7 +14,8 @@ import { ThemedProfilePageComponent } from './themed-profile-page.component';
SharedModule SharedModule
], ],
exports: [ exports: [
ProfilePageSecurityFormComponent ProfilePageSecurityFormComponent,
ProfilePageMetadataFormComponent
], ],
declarations: [ declarations: [
ProfilePageComponent, ProfilePageComponent,

View File

@@ -47,7 +47,7 @@
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope" [scope]="(searchOptions$ | async)?.scope"
[currentUrl]="searchLink" [currentUrl]="searchLink"
[scopes]="(scopeListRD$ | async)" [showScopeSelector]="true"
[inPlaceSearch]="inPlaceSearch" [inPlaceSearch]="inPlaceSearch"
[searchPlaceholder]="'search.search-form.placeholder' | translate"> [searchPlaceholder]="'search.search-form.placeholder' | translate">
</ds-search-form> </ds-search-form>

View File

@@ -55,11 +55,6 @@ export class SearchComponent implements OnInit {
*/ */
sortOptions$: Observable<SortOptions[]>; sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/** /**
* Emits true if were on a small screen * Emits true if were on a small screen
*/ */
@@ -137,9 +132,7 @@ export class SearchComponent implements OnInit {
).subscribe((results) => { ).subscribe((results) => {
this.resultsRD$.next(results); this.resultsRD$.next(results);
}); });
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
if (isEmpty(this.configuration$)) { if (isEmpty(this.configuration$)) {
this.configuration$ = this.searchConfigService.getCurrentConfiguration('default'); this.configuration$ = this.searchConfigService.getCurrentConfiguration('default');
} }

View File

@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
import { FindListOptions } from '../../core/data/request.models'; import { FindListOptions } from '../../core/data/request.models';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { ThemeService } from '../theme-support/theme.service';
describe('BrowseByComponent', () => { describe('BrowseByComponent', () => {
let comp: BrowseByComponent; let comp: BrowseByComponent;
let fixture: ComponentFixture<BrowseByComponent>; let fixture: ComponentFixture<BrowseByComponent>;
let themeService: ThemeService;
const mockItems = [ const mockItems = [
Object.assign(new Item(), { Object.assign(new Item(), {
id: 'fakeId-1', id: 'fakeId-1',
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
const paginationService = new PaginationServiceStub(paginationConfig); const paginationService = new PaginationServiceStub(paginationConfig);
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
CommonModule, CommonModule,
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
], ],
declarations: [], declarations: [],
providers: [ providers: [
{provide: PaginationService, useValue: paginationService} {provide: PaginationService, useValue: paginationService},
{ provide: ThemeService, useValue: themeService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();

View File

@@ -9,7 +9,8 @@ import { hasValue, isNotEmpty } from '../../empty.util';
export enum SelectorActionType { export enum SelectorActionType {
CREATE = 'create', CREATE = 'create',
EDIT = 'edit', 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 * Method called when an object has been selected
* @param dso The selected DSpaceObject * @param dso The selected DSpaceObject

View File

@@ -3,7 +3,7 @@
(keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="checkIfValidInput(form);close();"> (dsClickOutside)="checkIfValidInput(form);close();">
<input #inputField type="text" formControlName="metadataNameField" attr.aria-labelledby="fieldName" [(ngModel)]="value" id="name" [name]="name" <input [readonly]="disable" #inputField type="text" formControlName="metadataNameField" attr.aria-labelledby="fieldName" [(ngModel)]="value" id="name" [name]="name"
class="form-control suggestion_input" class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}" [ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)" [dsDebounce]="debounceTime" (onDebounce)="find($event)"

View File

@@ -60,4 +60,30 @@ describe('ValidationSuggestionsComponent', () => {
expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value); expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value);
}); });
}); });
describe('can edit input', () => {
describe('test input field readonly property when input disable is true', () => {
beforeEach(() => {
comp.disable = true;
fixture.detectChanges();
});
it('it should be true', () => {
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input'));
const element = input.nativeElement;
expect(element.readOnly).toBe(true);
});
});
describe('test input field readonly property when input disable is false', () => {
beforeEach(() => {
comp.disable = false;
fixture.detectChanges();
});
it('it should be true', () => {
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input'));
const element = input.nativeElement;
expect(element.readOnly).toBe(false);
});
});
});
}); });

View File

@@ -42,7 +42,10 @@ export class ValidationSuggestionsComponent extends InputSuggestionsComponent im
* The suggestions that should be shown * The suggestions that should be shown
*/ */
@Input() suggestions: InputSuggestion[] = []; @Input() suggestions: InputSuggestion[] = [];
/**
* The possibility to edit metadata
*/
@Input() disable;
constructor(private metadataFieldValidator: MetadataFieldValidator, constructor(private metadataFieldValidator: MetadataFieldValidator,
private objectUpdatesService: ObjectUpdatesService) { private objectUpdatesService: ObjectUpdatesService) {
super(); super();

View File

@@ -7,12 +7,17 @@ import {
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model'; import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
import { Context } from '../../core/shared/context.model'; import { Context } from '../../core/shared/context.model';
import * as uuidv4 from 'uuid/v4'; import * as uuidv4 from 'uuid/v4';
import { environment } from '../../../environments/environment';
let ogEnvironmentThemes;
describe('MetadataRepresentation decorator function', () => { describe('MetadataRepresentation decorator function', () => {
const type1 = 'TestType'; const type1 = 'TestType';
const type2 = 'TestType2'; const type2 = 'TestType2';
const type3 = 'TestType3'; const type3 = 'TestType3';
const type4 = 'RandomType'; const type4 = 'RandomType';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
let prefix; let prefix;
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
class Test3ItemSubmission { class Test3ItemSubmission {
} }
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
beforeEach(() => { beforeEach(() => {
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission); metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission); metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
// Register a metadata representation in the 'ancestor' theme
metadataRepresentationComponent(key + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestAncestorComponent);
metadataRepresentationComponent(key + typeUnthemed, MetadataRepresentationType.Item, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
} }
afterEach(() => {
environment.themes = ogEnvironmentThemes;
});
describe('If there\'s an exact match', () => { describe('If there\'s an exact match', () => {
it('should return the matching class', () => { it('should return the matching class', () => {
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace); const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
@@ -76,4 +97,55 @@ describe('MetadataRepresentation decorator function', () => {
}); });
}); });
}); });
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested entityType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any entityType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any entityType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeUnthemed, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
}); });

View File

@@ -3,6 +3,10 @@ import { hasNoValue, hasValue } from '../empty.util';
import { Context } from '../../core/shared/context.model'; import { Context } from '../../core/shared/context.model';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import {
resolveTheme,
DEFAULT_THEME, DEFAULT_CONTEXT
} from '../object-collection/shared/listable-object/listable-object.decorator';
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', { export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
providedIn: 'root', providedIn: 'root',
@@ -13,8 +17,6 @@ export const map = new Map();
export const DEFAULT_ENTITY_TYPE = 'Publication'; export const DEFAULT_ENTITY_TYPE = 'Publication';
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText; export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/** /**
* Decorator function to store metadata representation mapping * Decorator function to store metadata representation mapping
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
if (hasValue(entityAndMDRepMap)) { if (hasValue(entityAndMDRepMap)) {
const contextMap = entityAndMDRepMap.get(context); const contextMap = entityAndMDRepMap.get(context);
if (hasValue(contextMap)) { if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) { const match = resolveTheme(contextMap, theme);
return contextMap.get(theme); if (hasValue(match)) {
return match;
} }
if (hasValue(contextMap.get(DEFAULT_THEME))) { if (hasValue(contextMap.get(DEFAULT_THEME))) {
return contextMap.get(DEFAULT_THEME); return contextMap.get(DEFAULT_THEME);

View File

@@ -1,9 +1,18 @@
import { ThemeService } from '../theme-support/theme.service'; import { ThemeService } from '../theme-support/theme.service';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { ThemeConfig } from '../../../config/theme.model';
import { isNotEmpty } from '../empty.util';
export function getMockThemeService(themeName = 'base'): ThemeService { export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
return jasmine.createSpyObj('themeService', { const spy = jasmine.createSpyObj('themeService', {
getThemeName: themeName, getThemeName: themeName,
getThemeName$: observableOf(themeName) getThemeName$: observableOf(themeName),
getThemeConfigFor: undefined,
}); });
if (isNotEmpty(themes)) {
spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name));
}
return spy;
} }

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef, DebugElement } from '@angular/core'; import { ChangeDetectorRef, DebugElement } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -16,6 +16,7 @@ import { Notification } from '../models/notification.model';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
import { storeModuleConfig } from '../../../app.reducer'; import { storeModuleConfig } from '../../../app.reducer';
import { BehaviorSubject } from 'rxjs';
describe('NotificationComponent', () => { describe('NotificationComponent', () => {
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
deContent = fixture.debugElement.query(By.css('.notification-content')); deContent = fixture.debugElement.query(By.css('.notification-content'));
elContent = deContent.nativeElement; elContent = deContent.nativeElement;
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement; elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
spyOn(comp, 'remove');
}); });
it('should create component', () => { it('should create component', () => {
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
expect(elContent.innerHTML).toEqual(htmlContent); expect(elContent.innerHTML).toEqual(htmlContent);
}); });
describe('dismiss countdown', () => {
const TIMEOUT = 5000;
let isPaused$: BehaviorSubject<boolean>;
beforeEach(() => {
isPaused$ = new BehaviorSubject<boolean>(false);
comp.isPaused$ = isPaused$;
comp.notification = {
id: '1',
type: NotificationType.Info,
title: 'Notif. title',
content: 'test',
options: Object.assign(
new NotificationOptions(),
{ timeout: TIMEOUT }
),
html: true
};
});
it('should remove notification after timeout', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
describe('isPaused$', () => {
it('should pause countdown on true', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 2);
isPaused$.next(true);
tick(TIMEOUT);
expect(comp.remove).not.toHaveBeenCalled();
}));
it('should resume paused countdown on false', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 4);
isPaused$.next(true);
tick(TIMEOUT / 4);
isPaused$.next(false);
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
});
});
}); });

View File

@@ -1,4 +1,4 @@
import {of as observableOf, Observable } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
import { NotificationAnimationsStatus } from '../models/notification-animations-type'; import { NotificationAnimationsStatus } from '../models/notification-animations-type';
import { isNotEmpty } from '../../empty.util'; import { isNotEmpty } from '../../empty.util';
import { INotification } from '../models/notification.model'; import { INotification } from '../models/notification.model';
import { filter, first } from 'rxjs/operators';
@Component({ @Component({
selector: 'ds-notification', selector: 'ds-notification',
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
@Input() public notification = null as INotification; @Input() public notification = null as INotification;
/**
* Whether this notification's countdown should be paused
*/
@Input() public isPaused$: Observable<boolean> = observableOf(false);
// Progress bar variables // Progress bar variables
public title: Observable<string>; public title: Observable<string>;
public content: Observable<string>; public content: Observable<string>;
@@ -99,17 +105,21 @@ export class NotificationComponent implements OnInit, OnDestroy {
private instance = () => { private instance = () => {
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed); this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
if (this.count++ === this.steps) { this.isPaused$.pipe(
this.remove(); filter(paused => !paused),
// this.item.timeoutEnd!.emit(); first(),
} else if (!this.stopTime) { ).subscribe(() => {
if (this.showProgressBar) { if (this.count++ === this.steps) {
this.progressWidth += 100 / this.steps; this.remove();
} } else if (!this.stopTime) {
if (this.showProgressBar) {
this.progressWidth += 100 / this.steps;
}
this.timer = setTimeout(this.instance, (this.speed - this.diff)); this.timer = setTimeout(this.instance, (this.speed - this.diff));
} }
this.zone.run(() => this.cdr.detectChanges()); this.zone.run(() => this.cdr.detectChanges());
});
} }
public remove() { public remove() {

View File

@@ -1,7 +1,10 @@
<div class="notifications-wrapper position-fixed" [ngClass]="position"> <div class="notifications-wrapper position-fixed"
[ngClass]="position"
(mouseenter)="this.isPaused$.next(true);"
(mouseleave)="this.isPaused$.next(false);">
<ds-notification <ds-notification
class="notification" class="notification"
*ngFor="let a of notifications; let i = index" *ngFor="let a of notifications; let i = index"
[notification]="a"> [notification]="a" [isPaused$]="isPaused$">
</ds-notification> </ds-notification>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef } from '@angular/core'; import { ChangeDetectorRef } from '@angular/core';
import { NotificationsService } from '../notifications.service'; import { NotificationsService } from '../notifications.service';
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { cold } from 'jasmine-marbles';
export const bools = { f: false, t: true };
describe('NotificationsBoardComponent', () => { describe('NotificationsBoardComponent', () => {
let comp: NotificationsBoardComponent; let comp: NotificationsBoardComponent;
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
it('should have two notifications', () => { it('should have two notifications', () => {
expect(comp.notifications.length).toBe(2); expect(comp.notifications.length).toBe(2);
expect(fixture.debugElement.queryAll(By.css('ds-notification')).length).toBe(2);
});
describe('notification countdown', () => {
let wrapper;
beforeEach(() => {
wrapper = fixture.debugElement.query(By.css('div.notifications-wrapper'));
});
it('should not be paused by default', () => {
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should pause on mouseenter', () => {
wrapper.triggerEventHandler('mouseenter');
expect(comp.isPaused$).toBeObservable(cold('t', bools));
});
it('should resume on mouseleave', () => {
wrapper.triggerEventHandler('mouseenter');
wrapper.triggerEventHandler('mouseleave');
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should be passed to all notifications', () => {
fixture.debugElement.queryAll(By.css('ds-notification'))
.map(node => node.componentInstance)
.forEach(notification => {
expect(notification.isPaused$).toEqual(comp.isPaused$);
});
});
}); });
}) })

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { Subscription } from 'rxjs'; import { BehaviorSubject, Subscription } from 'rxjs';
import { difference } from 'lodash'; import { difference } from 'lodash';
import { NotificationsService } from '../notifications.service'; import { NotificationsService } from '../notifications.service';
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
public rtl = false; public rtl = false;
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight'; public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
/**
* Whether to pause the dismiss countdown of all notifications on the board
*/
public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(private service: NotificationsService, constructor(private service: NotificationsService,
private store: Store<AppState>, private store: Store<AppState>,
private cdr: ChangeDetectorRef) { private cdr: ChangeDetectorRef) {
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
} }
}); });
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.sub) { if (this.sub) {
this.sub.unsubscribe(); this.sub.unsubscribe();

View File

@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { ThemeService } from '../../../theme-support/theme.service';
const testType = 'TestType'; const testType = 'TestType';
const testContext = Context.Search; const testContext = Context.Search;
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let comp: ListableObjectComponentLoaderComponent; let comp: ListableObjectComponentLoaderComponent;
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>; let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
let themeService: ThemeService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective], declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
providers: [provideMockStore({})] providers: [
provideMockStore({}),
{ provide: ThemeService, useValue: themeService },
]
}).overrideComponent(ListableObjectComponentLoaderComponent, { }).overrideComponent(ListableObjectComponentLoaderComponent, {
set: { set: {
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
comp.viewMode = testViewMode; comp.viewMode = testViewMode;
comp.context = testContext; comp.context = testContext;
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any); spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
fixture.detectChanges(); fixture.detectChanges();
})); }));
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
it('should call the getListableObjectComponent function with the right types, view mode and context', () => { it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
}); });
it('should connectInputsAndOutputs of loaded component', () => {
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
});
}); });
describe('when the object is an item and viewMode is a list', () => { describe('when the object is an item and viewMode is a list', () => {
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let reloadedObject: any; let reloadedObject: any;
beforeEach(() => { beforeEach(() => {
spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null); spyOn((comp as any), 'instantiateComponent').and.returnValue(null);
spyOn((comp as any).contentChange, 'emit').and.returnValue(null); spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance; listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
reloadedObject = 'object'; reloadedObject = 'object';
}); });
it('should pass it on connectInputsAndOutputs', fakeAsync(() => { it('should re-instantiate the listable component', fakeAsync(() => {
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled(); expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
(listableComponent as any).reloadedObject.emit(reloadedObject); (listableComponent as any).reloadedObject.emit(reloadedObject);
tick(); tick();
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled(); expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
})); }));
it('should re-emit it as a contentChange', fakeAsync(() => { it('should re-emit it as a contentChange', fakeAsync(() => {

View File

@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
if (reloadedObject) { if (reloadedObject) {
this.compRef.destroy(); this.compRef.destroy();
this.object = reloadedObject; this.object = reloadedObject;
this.connectInputsAndOutputs(); this.instantiateComponent(reloadedObject);
this.contentChange.emit(reloadedObject); this.contentChange.emit(reloadedObject);
} }
}); });

View File

@@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator'; import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
import { Context } from '../../../../core/shared/context.model'; import { Context } from '../../../../core/shared/context.model';
import { environment } from '../../../../../environments/environment';
let ogEnvironmentThemes;
describe('ListableObject decorator function', () => { describe('ListableObject decorator function', () => {
const type1 = 'TestType'; const type1 = 'TestType';
const type2 = 'TestType2'; const type2 = 'TestType2';
const type3 = 'TestType3'; const type3 = 'TestType3';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
class Test1List { class Test1List {
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
class Test3DetailedSubmission { class Test3DetailedSubmission {
} }
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
beforeEach(() => { beforeEach(() => {
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
listableObjectComponent(type3, ViewMode.ListElement)(Test3List); listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission); listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
// Register a metadata representation in the 'ancestor' theme
listableObjectComponent(typeAncestor, ViewMode.ListElement, Context.Any, 'ancestor')(TestAncestorComponent);
listableObjectComponent(typeUnthemed, ViewMode.ListElement, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
});
afterEach(() => {
environment.themes = ogEnvironmentThemes;
}); });
const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement); const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement);
@@ -80,4 +101,55 @@ describe('ListableObject decorator function', () => {
}); });
}); });
}); });
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested objectType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any objectType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any objectType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its objectType', () => {
const component = getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its objectType', () => {
const component = getListableObjectComponent([typeUnthemed], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
}); });

View File

@@ -1,14 +1,23 @@
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Context } from '../../../../core/shared/context.model'; import { Context } from '../../../../core/shared/context.model';
import { hasNoValue, hasValue } from '../../../empty.util'; import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
import {
DEFAULT_CONTEXT,
DEFAULT_THEME
} from '../../../metadata-representation/metadata-representation.decorator';
import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObject } from '../listable-object.model'; import { ListableObject } from '../listable-object.model';
import { environment } from '../../../../../environments/environment';
import { ThemeConfig } from '../../../../../config/theme.model';
import { InjectionToken } from '@angular/core';
export const DEFAULT_VIEW_MODE = ViewMode.ListElement; export const DEFAULT_VIEW_MODE = ViewMode.ListElement;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/**
* Factory to allow us to inject getThemeConfigFor so we can mock it in tests
*/
export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', {
providedIn: 'root',
factory: () => getThemeConfigFor
});
const map = new Map(); const map = new Map();
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
if (hasValue(typeModeMap)) { if (hasValue(typeModeMap)) {
const contextMap = typeModeMap.get(context); const contextMap = typeModeMap.get(context);
if (hasValue(contextMap)) { if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) { const match = resolveTheme(contextMap, theme);
return contextMap.get(theme); if (hasValue(match)) {
return match;
} }
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) { if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
bestMatchValue = 3; bestMatchValue = 3;
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
} }
return bestMatch; return bestMatch;
} }
/**
* Searches for a ThemeConfig by its name;
*/
export const getThemeConfigFor = (themeName: string): ThemeConfig => {
return environment.themes.find(theme => theme.name === themeName);
};
/**
* Find a match in the given map for the given theme name, taking theme extension into account
*
* @param contextMap A map of theme names to components
* @param themeName The name of the theme to check
* @param checkedThemeNames The list of theme names that are already checked
*/
export const resolveTheme = (contextMap: Map<any, any>, themeName: string, checkedThemeNames: string[] = []): any => {
const match = contextMap.get(themeName);
if (hasValue(match)) {
return match;
} else {
const cfg = getThemeConfigFor(themeName);
if (hasValue(cfg) && isNotEmpty(cfg.extends)) {
const nextTheme = cfg.extends;
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
if (checkedThemeNames.includes(nextTheme)) {
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
} else {
return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames);
}
}
}
};

View File

@@ -0,0 +1,19 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="selectObject(undefined)" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.' + action + '.' + objectType.toString().toLowerCase() + '.button'| translate }}</button>
<h3 class="position-relative py-1 my-3 font-weight-normal">
<hr>
<div id="create-community-or-separator" class="text-center position-absolute w-100">
<span class="px-4 bg-white">or</span>
</div>
</h3>
<h5 class="px-2">{{'dso-selector.' + action + '.' + objectType.toString().toLowerCase() + '.input-header' | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,3 @@
#create-community-or-separator {
top: 0;
}

View File

@@ -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<ScopeSelectorModalComponent>;
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);
});
});

View File

@@ -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<DSpaceObject>();
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
super(activeModal, route);
}
navigate(dso: DSpaceObject) {
/* Handle complex search navigation in underlying component */
this.scopeChange.emit(dso);
}
}

View File

@@ -1,17 +1,14 @@
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="row" action="/search"> <form #form="ngForm" (ngSubmit)="onSubmit(form.value)" action="/search">
<div *ngIf="isNotEmpty(scopes)" class="col-12 col-sm-3"> <div>
<select [(ngModel)]="scope" name="scope" class="form-control" aria-label="Search scope" (change)="onScopeChange($event.target.value)" tabindex="0"> <div class="form-group input-group">
<option value>{{'search.form.search_dspace' | translate}}</option> <div *ngIf="showScopeSelector === true" class="input-group-prepend">
<option *ngFor="let scopeOption of scopes" [value]="scopeOption.id">{{scopeOption?.name ? scopeOption.name : 'search.form.search_dspace' | translate}}</option> <button class="scope-button btn btn-outline-secondary text-truncate" [ngbTooltip]="(selectedScope | async)?.name" type="button" (click)="openScopeModal()">{{(selectedScope | async)?.name || ('search.form.scope.all' | translate)}}</button>
</select> </div>
</div> <input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}"
<div [ngClass]="{'col-sm-9': isNotEmpty(scopes)}" class="col-12"> [placeholder]="searchPlaceholder">
<div class="form-group input-group"> <span class="input-group-append">
<input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}"
[placeholder]="searchPlaceholder">
<span class="input-group-append">
<button type="submit" class="search-button btn btn-{{brandColor}}"><i class="fas fa-search"></i> {{ ('search.form.search' | translate) }}</button> <button type="submit" class="search-button btn btn-{{brandColor}}"><i class="fas fa-search"></i> {{ ('search.form.search' | translate) }}</button>
</span> </span>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -3,3 +3,7 @@
background-color: var(--bs-input-bg); background-color: var(--bs-input-bg);
color: var(--bs-input-color); color: var(--bs-input-color);
} }
.scope-button {
max-width: $search-form-scope-max-width;
}

View File

@@ -8,13 +8,11 @@ import { Community } from '../../core/shared/community.model';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { SearchService } from '../../core/shared/search/search.service'; 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 { PaginationService } from '../../core/pagination/pagination.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
describe('SearchFormComponent', () => { describe('SearchFormComponent', () => {
let comp: SearchFormComponent; let comp: SearchFormComponent;
@@ -35,7 +33,8 @@ describe('SearchFormComponent', () => {
useValue: {} useValue: {}
}, },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: SearchConfigurationService, useValue: searchConfigService } { provide: SearchConfigurationService, useValue: searchConfigService },
{ provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} }
], ],
declarations: [SearchFormComponent] declarations: [SearchFormComponent]
}).compileComponents(); }).compileComponents();
@@ -48,24 +47,6 @@ describe('SearchFormComponent', () => {
el = de.nativeElement; 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', () => { it('should not display scopes when empty', () => {
fixture.detectChanges(); fixture.detectChanges();
const select = de.query(By.css('select')); const select = de.query(By.css('select'));
@@ -84,17 +65,17 @@ describe('SearchFormComponent', () => {
})); }));
it('should select correct scope option in scope select', fakeAsync(() => { it('should select correct scope option in scope select', fakeAsync(() => {
comp.scopes = objects;
fixture.detectChanges();
fixture.detectChanges();
comp.showScopeSelector = true;
const testCommunity = objects[1]; const testCommunity = objects[1];
comp.scope = testCommunity.id; comp.selectedScope.next(testCommunity);
fixture.detectChanges(); fixture.detectChanges();
tick(); 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(() => { // it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => {
// comp.query = 'Test String' // comp.query = 'Test String'
@@ -118,7 +99,7 @@ describe('SearchFormComponent', () => {
// //
// expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query }); // expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query });
// })); // }));
}); });
export const objects: DSpaceObject[] = [ export const objects: DSpaceObject[] = [
Object.assign(new Community(), { Object.assign(new Community(), {

View File

@@ -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 { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { isNotEmpty } from '../empty.util'; import { isNotEmpty } from '../empty.util';
@@ -6,6 +6,12 @@ import { SearchService } from '../../core/shared/search/search.service';
import { currentPath } from '../utils/route.utils'; import { currentPath } from '../utils/route.utils';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.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. * 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 * Component that represents the search form
*/ */
export class SearchFormComponent { export class SearchFormComponent implements OnInit {
/** /**
* The search query * The search query
*/ */
@@ -39,12 +45,9 @@ export class SearchFormComponent {
@Input() @Input()
scope = ''; scope = '';
@Input() currentUrl: string; selectedScope: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
/** @Input() currentUrl: string;
* The available scopes
*/
@Input() scopes: DSpaceObject[];
/** /**
* Whether or not the search button should be displayed large * Whether or not the search button should be displayed large
@@ -61,15 +64,33 @@ export class SearchFormComponent {
*/ */
@Input() searchPlaceholder: string; @Input() searchPlaceholder: string;
/**
* Defines whether or not to show the scope selector
*/
@Input() showScopeSelector = false;
/** /**
* Output the search data on submit * Output the search data on submit
*/ */
@Output() submitSearch = new EventEmitter<any>(); @Output() submitSearch = new EventEmitter<any>();
constructor(private router: Router, private searchService: SearchService, constructor(private router: Router,
private searchService: SearchService,
private paginationService: PaginationService, 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 * Updates the search when the current scope has been changed
* @param {string} scope The new scope * @param {string} scope The new scope
*/ */
onScopeChange(scope: string) { onScopeChange(scope: DSpaceObject) {
this.updateSearch({ scope }); this.updateSearch({ scope: scope ? scope.uuid : undefined });
} }
/** /**
@@ -94,11 +115,11 @@ export class SearchFormComponent {
* @param data Updated parameters * @param data Updated parameters
*/ */
updateSearch(data: any) { updateSearch(data: any) {
const queryParams = Object.assign({}, data); const queryParams = Object.assign({}, data);
const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID); const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID);
queryParams[pageParam] = 1; queryParams[pageParam] = 1;
this.router.navigate(this.getSearchLinkParts(), { this.router.navigate(this.getSearchLinkParts(), {
queryParams: queryParams, queryParams: queryParams,
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
@@ -131,4 +152,15 @@ export class SearchFormComponent {
} }
return this.getSearchLink().split('/'); 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);
});
}
} }

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