diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7758020724..539fd740ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,11 +29,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v1 + uses: actions/checkout@v2 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} @@ -82,7 +82,7 @@ jobs: # Upload coverage reports to Codecov (for Node v12 only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 if: matrix.node-version == '12.x' # Using docker-compose start backend using CI configuration diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..00ec2fa8f7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,78 @@ +# DSpace Docker image build for hub.docker.com +name: Docker images + +# Run this Build for all pushes to 'main' or maintenance branches, or tagged releases. +# Also run for PRs to ensure PR doesn't break Docker build process +on: + push: + branches: + - main + - 'dspace-**' + tags: + - 'dspace-**' + pull_request: + +jobs: + docker: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' + if: github.repository == 'dspace/dspace-angular' + runs-on: ubuntu-latest + env: + # Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action) + # For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image. + # For a new commit on other branches, use the branch name as the tag for Docker image. + # For a new tag, copy that tag name as the tag for Docker image. + IMAGE_TAGS: | + type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=tag + # Define default tag "flavor" for docker/metadata-action per + # https://github.com/docker/metadata-action#flavor-input + # We turn off 'latest' tag by default. + TAGS_FLAVOR: | + latest=false + + steps: + # https://github.com/actions/checkout + - name: Checkout codebase + uses: actions/checkout@v2 + + # https://github.com/docker/setup-buildx-action + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v1 + + # https://github.com/docker/login-action + - name: Login to DockerHub + # Only login if not a PR, as PRs only trigger a Docker build and not a push + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + ############################################### + # Build/Push the 'dspace/dspace-angular' image + ############################################### + # https://github.com/docker/metadata-action + # Get Metadata for docker_build step below + - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image + id: meta_build + uses: docker/metadata-action@v3 + with: + images: dspace/dspace-angular + tags: ${{ env.IMAGE_TAGS }} + flavor: ${{ env.TAGS_FLAVOR }} + + # https://github.com/docker/build-push-action + - name: Build and push 'dspace-angular' image + id: docker_build + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + # For pull requests, we run the Docker build (to ensure no PR changes break the build), + # but we ONLY do an image push to DockerHub if it's NOT a PR + push: ${{ github.event_name != 'pull_request' }} + # Use tags / labels provided by 'docker/metadata-action' above + tags: ${{ steps.meta_build.outputs.tags }} + labels: ${{ steps.meta_build.outputs.labels }} diff --git a/.gitignore b/.gitignore index f110ba720c..026110f222 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,6 @@ npm-debug.log /build/ -/src/environments/environment.ts -/src/environments/environment.dev.ts -/src/environments/environment.prod.ts - /coverage /dist/ diff --git a/Dockerfile b/Dockerfile index db9983cace..2d98971112 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # This image will be published as dspace/dspace-angular -# See https://dspace-labs.github.io/DSpace-Docker-Images/ for usage details +# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details -FROM node:12-alpine +FROM node:14-alpine WORKDIR /app ADD . /app/ EXPOSE 4000 diff --git a/LICENSE b/LICENSE index f55d21fe42..b381f6d968 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -DSpace source code BSD License: +BSD 3-Clause License Copyright (c) 2002-2021, LYRASIS. All rights reserved. @@ -13,13 +13,12 @@ notice, this list of conditions and the following disclaimer. notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -- Neither the name DuraSpace nor the name of the DSpace Foundation -nor the names of its contributors may be used to endorse or promote -products derived from this software without specific prior written -permission. +- Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, @@ -29,11 +28,4 @@ OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. - - -DSpace uses third-party libraries which may be distributed under -different licenses to the above. Information about these licenses -is detailed in the LICENSES_THIRD_PARTY file at the root of the source -tree. You must agree to the terms of these licenses, in addition to -the above DSpace source code license, in order to use this software. +DAMAGE. \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..44bbf95d2a --- /dev/null +++ b/NOTICE @@ -0,0 +1,28 @@ +Licenses of Third-Party Libraries +================================= + +DSpace uses third-party libraries which may be distributed under +different licenses than specified in our LICENSE file. Information +about these licenses is detailed in the LICENSES_THIRD_PARTY file at +the root of the source tree. You must agree to the terms of these +licenses, in addition to the DSpace source code license, in order to +use this software. + +Licensing Notices +================= + +[July 2019] DuraSpace joined with LYRASIS (another 501(c)3 organization) in July 2019. +LYRASIS holds the copyrights of DuraSpace. + +[July 2009] Fedora Commons joined with the DSpace Foundation and began operating under +the new name DuraSpace in July 2009. DuraSpace holds the copyrights of +the DSpace Foundation, Inc. + +[July 2007] The DSpace Foundation, Inc. is a 501(c)3 corporation established in July 2007 +with a mission to promote and advance the dspace platform enabling management, +access and preservation of digital works. The Foundation was able to transfer +the legal copyright from Hewlett-Packard Company (HP) and Massachusetts +Institute of Technology (MIT) to the DSpace Foundation in October 2007. Many +of the files in the source code may contain a copyright statement stating HP +and MIT possess the copyright, in these instances please note that the copy +right has transferred to the DSpace foundation, and subsequently to DuraSpace. \ No newline at end of file diff --git a/README.md b/README.md index 69b6132478..176c90d91b 100644 --- a/README.md +++ b/README.md @@ -102,48 +102,87 @@ Installing ### Configuring -Default configuration file is located in `src/environments/` folder. +Default configuration file is located in `config/` 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 override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point. -- Create a new `environment.dev.ts` file in `src/environments/` for a `development` environment; -- Create a new `environment.prod.ts` file in `src/environments/` for a `production` environment; +- Create a new `config.(dev or development).yml` file in `config/` for a `development` environment; +- Create a new `config.(prod or production).yml` file in `config/` for a `production` environment; -The server settings can also be overwritten using an environment file. +The settings can also be overwritten using an environment file or environment variables. This file should be called `.env` and be placed in the project root. -The following settings can be overwritten in this file: +The following non-convention settings: ```bash DSPACE_HOST # The host name of the angular application DSPACE_PORT # The port number of the angular application DSPACE_NAMESPACE # The namespace of the angular application DSPACE_SSL # Whether the angular application uses SSL [true/false] +``` -DSPACE_REST_HOST # The host name of the REST application -DSPACE_REST_PORT # The port number of the REST application -DSPACE_REST_NAMESPACE # The namespace of the REST application -DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false] +All other settings can be set using the following convention for naming the environment variables: + +1. replace all `.` with `_` +2. convert all characters to upper case +3. prefix with `DSPACE_` + +e.g. + +```bash +# The host name of the REST application +rest.host => DSPACE_REST_HOST + +# The port number of the REST application +rest.port => DSPACE_REST_PORT + +# The namespace of the REST application +rest.nameSpace => DSPACE_REST_NAMESPACE + +# Whether the angular REST uses SSL [true/false] +rest.ssl => DSPACE_REST_SSL + +cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT +auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE +``` + +The equavelant to the non-conventional legacy settings: + +```bash +DSPACE_UI_HOST => DSPACE_HOST +DSPACE_UI_PORT => DSPACE_PORT +DSPACE_UI_NAMESPACE => DSPACE_NAMESPACE +DSPACE_UI_SSL => DSPACE_SSL ``` The same settings can also be overwritten by setting system environment variables instead, E.g.: ```bash export DSPACE_HOST=api7.dspace.org +export DSPACE_UI_PORT=4200 ``` -The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides **`environment.(prod, dev or test).ts`** overrides **`environment.common.ts`** +The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`** -These configuration sources are collected **at build time**, and written to `src/environments/environment.ts`. At runtime the configuration is fixed, and neither `.env` nor the process' environment will be consulted. +These configuration sources are collected **at run time**, and written to `dist/browser/assets/config.json` for production and `src/app/assets/config.json` for development. + +The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`. #### Using environment variables in code To use environment variables in a UI component, use: ```typescript -import { environment } from '../environment.ts'; +import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +... +constructor(@Inject(APP_CONFIG) private appConfig: AppConfig) {} +... ``` -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` +or + +```typescript +import { environment } from '../environment.ts'; +``` Running the app @@ -230,7 +269,9 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con The test files can be found in the `./cypress/integration/` folder. -Before you can run e2e tests, you MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `environment.prod.ts` or `environment.common.ts`. You may override this using env variables, see [Configuring](#configuring). +Before you can run e2e tests, two things are required: +1. 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 `config.prod.yml` or `config.yml`. You may override this using env variables, see [Configuring](#configuring). +2. Your backend MUST include our Entities Test Data set. Some tests run against a (currently hardcoded) Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data 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. @@ -309,49 +350,85 @@ File Structure ``` dspace-angular -├── README.md * This document -├── app.yaml * Application manifest file -├── config * Folder for configuration files -│   ├── environment.default.js * Default configuration files -│   └── environment.test.js * Test configuration files -├── docs * Folder for documentation +├── config * +│ └── config.yml * Default app config ├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests -│   ├── integration * Folder for e2e/integration test files -│   ├── fixtures * Folder for any fixtures needed by e2e tests -│   ├── plugins * Folder for Cypress plugins (if any) -│   ├── support * Folder for global e2e test actions/commands (run for all tests) -│   └── tsconfig.json * TypeScript configuration file for e2e tests +│ ├── downloads * +│ ├── fixtures * Folder for e2e/integration test files +│ ├── integration * Folder for any fixtures needed by e2e tests +│ ├── plugins * Folder for Cypress plugins (if any) +│ ├── support * Folder for global e2e test actions/commands (run for all tests) +│ └── tsconfig.json * TypeScript configuration file for e2e tests +├── docker * See docker/README.md for details +│ ├── cli.assetstore.yml * +│ ├── cli.ingest.yml * +│ ├── cli.yml * +│ ├── db.entities.yml * +│ ├── docker-compose-ci.yml * +│ ├── docker-compose-rest.yml * +│ ├── docker-compose.yml * +│ └── README.md * +├── docs * Folder for documentation +│ └── Configuration.md * Configuration documentation +├── scripts * +│ ├── merge-i18n-files.ts * +│ ├── serve.ts * +│ ├── sync-i18n-files.ts * +│ ├── test-rest.ts * +│ └── webpack.js * +├── src * The source of the application +│ ├── app * The source code of the application, subdivided by module/page. +│ ├── assets * Folder for static resources +│ │ ├── fonts * Folder for fonts +│ │ ├── i18n * Folder for i18n translations +│ │ └── images * Folder for images +│ ├── backend * Folder containing a mock of the REST API, hosted by the express server +│ ├── config * +│ ├── environments * +│ │ ├── environment.production.ts * Production configuration files +│ │ ├── environment.test.ts * Test configuration files +│ │ └── environment.ts * Default (development) configuration files +│ ├── mirador-viewer * +│ ├── modules * +│ ├── ngx-translate-loaders * +│ ├── styles * Folder containing global styles +│ ├── themes * Folder containing available themes +│ │ ├── custom * Template folder for creating a custom theme +│ │ └── dspace * Default 'dspace' theme +│ ├── index.csr.html * The index file for client side rendering fallback +│ ├── index.html * The index file +│ ├── main.browser.ts * The bootstrap file for the client +│ ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server +│ ├── polyfills.ts * +│ ├── robots.txt * The robots.txt file +│ ├── test.ts * +│ └── typings.d.ts * +├── webpack * +│ ├── helpers.ts * Webpack helpers +│ ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for browser build +│ ├── webpack.common.ts * Webpack (https://webpack.github.io/) common build config +│ ├── webpack.mirador.config.ts * Webpack (https://webpack.github.io/) config for mirador config build +│ ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for prod build +│ └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build +├── angular.json * Angular CLI (https://angular.io/cli) configuration +├── cypress.json * Cypress Test (https://www.cypress.io/) configuration +├── Dockerfile * ├── karma.conf.js * Karma configuration file for Unit Test +├── LICENSE * +├── LICENSES_THIRD_PARTY * ├── nodemon.json * Nodemon (https://nodemon.io/) configuration ├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. -├── postcss.config.js * PostCSS (http://postcss.org/) configuration file -├── src * The source of the application -│   ├── app * The source code of the application, subdivided by module/page. -│   ├── assets * Folder for static resources -│   │   ├── fonts * Folder for fonts -│   │   ├── i18n * Folder for i18n translations -│   | └── en.json5 * i18n translations for English -│   │   └── images * Folder for images -│   ├── backend * Folder containing a mock of the REST API, hosted by the express server -│   ├── config * -│   ├── index.csr.html * The index file for client side rendering fallback -│   ├── index.html * The index file -│   ├── main.browser.ts * The bootstrap file for the client -│   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server -│   ├── robots.txt * The robots.txt file -│   ├── modules * -│   ├── styles * Folder containing global styles -│   └── themes * Folder containing available themes -│      ├── custom * Template folder for creating a custom theme -│      └── dspace * Default 'dspace' theme -├── tsconfig.json * TypeScript config +├── postcss.config.js * PostCSS (http://postcss.org/) configuration +├── README.md * This document +├── SECURITY.md * +├── server.ts * Angular Universal Node.js Express server +├── tsconfig.app.json * TypeScript config for browser (app) +├── tsconfig.json * TypeScript common config +├── tsconfig.server.json * TypeScript config for server +├── tsconfig.spec.json * TypeScript config for tests +├── tsconfig.ts-node.json * TypeScript config for using ts-node directly ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── typedoc.json * TYPEDOC configuration -├── webpack * Webpack (https://webpack.github.io/) config directory -│   ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for client build -│   ├── webpack.common.ts * -│   ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for production build -│   └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build └── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) ``` @@ -449,4 +526,8 @@ DSpace uses GitHub to track issues: License ------- -This project's source code is made available under the DSpace BSD License: http://www.dspace.org/license +DSpace source code is freely available under a standard [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause). +The full license is available in the [LICENSE](LICENSE) file or online at http://www.dspace.org/license/ + +DSpace uses third-party libraries which may be distributed under different licenses. Those licenses are listed +in the [LICENSES_THIRD_PARTY](LICENSES_THIRD_PARTY) file. diff --git a/angular.json b/angular.json index c6607fc80a..a0a4cd8ea1 100644 --- a/angular.json +++ b/angular.json @@ -68,6 +68,12 @@ }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ], "optimization": true, "outputHashing": "all", "extractCss": true, @@ -139,6 +145,16 @@ } ], "scripts": [] + }, + "configurations": { + "test": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.test.ts" + } + ] + } } }, "lint": { @@ -183,7 +199,13 @@ "configurations": { "production": { "sourceMap": false, - "optimization": true + "optimization": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ] } } }, diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 0000000000..a420ca4302 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1,2 @@ +config.*.yml +!config.example.yml diff --git a/config/config.example.yml b/config/config.example.yml new file mode 100644 index 0000000000..1e035889a5 --- /dev/null +++ b/config/config.example.yml @@ -0,0 +1,233 @@ +# NOTE: will log all redux actions and transfers in console +debug: false + +# Angular Universal server settings +# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg. +ui: + ssl: false + host: localhost + port: 4000 + # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: / + # The rateLimiter settings limit each IP to a 'max' of 500 requests per 'windowMs' (1 minute). + rateLimiter: + windowMs: 60000 # 1 minute + max: 500 # limit each IP to 500 requests per windowMs + +# The REST API server settings +# NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. +rest: + ssl: true + host: api7.dspace.org + port: 443 + # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: /server + +# Caching settings +cache: + # NOTE: how long should objects be cached for by default + msToLive: + default: 900000 # 15 minutes + control: max-age=60 # revalidate browser + autoSync: + defaultTime: 0 + maxBufferSize: 100 + timePerMethod: + PATCH: 3 # time in seconds + +# Authentication settings +auth: + # Authentication UI settings + ui: + # the amount of time before the idle warning is shown + timeUntilIdle: 900000 # 15 minutes + # the amount of time the user has to react after the idle warning is shown before they are logged out. + idleGracePeriod: 300000 # 5 minutes + # Authentication REST settings + rest: + # If the rest token expires in less than this amount of time, it will be refreshed automatically. + # This is independent from the idle warning. + timeLeftBeforeTokenRefresh: 120000 # 2 minutes + +# Form settings +form: + # NOTE: Map server-side validators to comparative Angular form validators + validatorMap: + required: required + regex: pattern + +# Notification settings +notifications: + rtl: false + position: + - top + - right + maxStack: 8 + # NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically + timeOut: 5000 # 5 second + clickToClose: true + # NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' + animate: scale + +# Submission settings +submission: + autosave: + # NOTE: which metadata trigger an autosave + metadata: [] + # NOTE: after how many time (milliseconds) submission is saved automatically + # eg. timer: 5 * (1000 * 60); // 5 minutes + timer: 0 + icons: + metadata: + # NOTE: example of configuration + # # NOTE: metadata name + # - name: dc.author + # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # style: fas fa-user + - name: dc.author + style: fas fa-user + # default configuration + - name: default + style: '' + authority: + confidence: + # NOTE: example of configuration + # # NOTE: confidence value + # - name: dc.author + # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # style: fa-user + - value: 600 + style: text-success + - value: 500 + style: text-info + - value: 400 + style: text-warning + # default configuration + - value: default + style: text-muted + +# Default Language in which the UI will be rendered if the user's browser language is not an active language +defaultLanguage: en + +# Languages. DSpace Angular holds a message catalog for each of the following languages. +# When set to active, users will be able to switch to the use of this language in the user interface. +languages: + - code: en + label: English + active: true + - code: cs + label: Čeština + active: true + - code: de + label: Deutsch + active: true + - code: es + label: Español + active: true + - code: fr + label: Français + active: true + - code: lv + label: Latviešu + active: true + - code: hu + label: Magyar + active: true + - code: nl + label: Nederlands + active: true + - code: pt-PT + label: Português + active: true + - code: pt-BR + label: Português do Brasil + active: true + - code: fi + label: Suomi + active: true + +# Browse-By Pages +browseBy: + # Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10 + # Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30 + # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900 + +# Item Page Config +item: + edit: + undoTimeout: 10000 # 10 seconds + +# Collection Page Config +collection: + edit: + undoTimeout: 10000 # 10 seconds + +# Theme Config +themes: + # Add additional themes here. In the case where multiple themes match a route, the first one + # in this list will get priority. It is advisable to always have a theme that matches + # every route as the last one + # + # # A theme with a handle property will match the community, collection or item with the given + # # handle, and all collections and/or items within it + # - name: 'custom', + # handle: '10673/1233' + # + # # A theme with a regex property will match the route using a regular expression. If it + # # matches the route for a community or collection it will also apply to all collections + # # and/or items within it + # - name: 'custom', + # regex: 'collections\/e8043bc2.*' + # + # # A theme with a uuid property will match the community, collection or item with the given + # # ID, and all collections and/or items within it + # - name: 'custom', + # uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + # + # # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found + # # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default. + # - name: 'custom-A', + # extends: 'custom-B', + # # Any of the matching properties above can be used + # handle: '10673/34' + # + # - name: 'custom-B', + # extends: 'custom', + # handle: '10673/12' + # + # # A theme with only a name will match every route + # name: 'custom' + # + # # This theme will use the default bootstrap styling for DSpace components + # - name: BASE_THEME_NAME + # + - name: dspace + headTags: + - tagName: link + attributes: + rel: icon + href: assets/dspace/images/favicons/favicon.ico + sizes: any + - tagName: link + attributes: + rel: icon + href: assets/dspace/images/favicons/favicon.svg + type: image/svg+xml + - tagName: link + attributes: + rel: apple-touch-icon + href: assets/dspace/images/favicons/apple-touch-icon.png + - tagName: link + attributes: + rel: manifest + href: assets/dspace/images/favicons/manifest.webmanifest + +# Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video'). +# For images, this enables a gallery viewer where you can zoom or page through images. +# For videos, this enables embedded video streaming +mediaViewer: + image: false + video: false diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000000..b5eecd112f --- /dev/null +++ b/config/config.yml @@ -0,0 +1,5 @@ +rest: + ssl: true + host: api7.dspace.org + port: 443 + nameSpace: /server diff --git a/cypress.json b/cypress.json index cded267c48..e06de8e4c5 100644 --- a/cypress.json +++ b/cypress.json @@ -5,5 +5,6 @@ "screenshotsFolder": "cypress/screenshots", "pluginsFile": "cypress/plugins/index.ts", "fixturesFolder": "cypress/fixtures", - "baseUrl": "http://localhost:4000" + "baseUrl": "http://localhost:4000", + "retries": 2 } \ No newline at end of file diff --git a/cypress/.gitignore b/cypress/.gitignore new file mode 100644 index 0000000000..99bd2a6312 --- /dev/null +++ b/cypress/.gitignore @@ -0,0 +1,2 @@ +screenshots/ +videos/ diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index a2bfbe6a5b..859c765d2e 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -53,7 +53,7 @@ describe('Search Page', () => { // Click to display grid view // TODO: These buttons should likely have an easier way to uniquely select - cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?spc.sf=score&spc.sd=DESC&view=grid"] > .fas').click(); + cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?view=grid"] > .fas').click(); // tag must be loaded cy.get('ds-search-page').should('exist'); diff --git a/docker/README.md b/docker/README.md index 747db22143..a2f4ef3362 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,6 +4,20 @@ :warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario. *** +## 'Dockerfile' in root directory +This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' + +``` +docker build -t dspace/dspace-angular:dspace-7_x . +``` + +This image is built *automatically* after each commit is made to the `main` branch. + +Admins to our DockerHub repo can manually publish with the following command. +``` +docker push dspace/dspace-angular:dspace-7_x +``` + ## docker directory - docker-compose.yml - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. @@ -15,10 +29,6 @@ - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. - cli.assetstore.yml - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. -- environment.dev.ts - - Environment file for running DSpace Angular in Docker -- local.cfg - - Environment file for running the DSpace 7 REST API in Docker. ## To refresh / pull DSpace images from Dockerhub diff --git a/docker/cli.yml b/docker/cli.yml index 36f63b2cff..54b83d4503 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -18,10 +18,19 @@ services: dspace-cli: image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" container_name: dspace-cli - #environment: + environment: + # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. + # See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml + # __P__ => "." (e.g. dspace__P__dir => dspace.dir) + # __D__ => "-" (e.g. google__D__metadata => google-metadata) + # dspace.dir + dspace__P__dir: /dspace + # db.url: Ensure we are using the 'dspacedb' image for our database + db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + # solr.server: Ensure we are using the 'dspacesolr' image for Solr + solr__P__server: http://dspacesolr:8983/solr volumes: - "assetstore:/dspace/assetstore" - - "./local.cfg:/dspace/config/local.cfg" entrypoint: /dspace/bin/dspace command: help networks: diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index d2d02f0a55..a895314a17 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -17,6 +17,19 @@ services: # DSpace (backend) webapp container dspace: container_name: dspace + environment: + # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. + # See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml + # __P__ => "." (e.g. dspace__P__dir => dspace.dir) + # __D__ => "-" (e.g. google__D__metadata => google-metadata) + # dspace.dir, dspace.server.url and dspace.ui.url + dspace__P__dir: /dspace + dspace__P__server__P__url: http://localhost:8080/server + dspace__P__ui__P__url: http://localhost:4000 + # db.url: Ensure we are using the 'dspacedb' image for our database + db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + # solr.server: Ensure we are using the 'dspacesolr' image for Solr + solr__P__server: http://dspacesolr:8983/solr depends_on: - dspacedb image: dspace/dspace:dspace-7_x-test @@ -29,7 +42,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - - "./local.cfg:/dspace/config/local.cfg" # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat @@ -64,7 +76,7 @@ services: dspacesolr: container_name: dspacesolr # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.8 + image: solr:8.11-slim # Needs main 'dspace' container to start first to guarantee access to solr_configs depends_on: - dspace diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index 370ccbbdf1..b73f1b7a39 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -13,10 +13,32 @@ version: '3.7' networks: dspacenet: + ipam: + config: + # Define a custom subnet for our DSpace network, so that we can easily trust requests from host to container. + # If you customize this value, be sure to customize the 'proxies.trusted.ipranges' env variable below. + - subnet: 172.23.0.0/16 services: # DSpace (backend) webapp container dspace: container_name: dspace + environment: + # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. + # See https://github.com/DSpace/DSpace/blob/main/dspace/config/config-definition.xml + # __P__ => "." (e.g. dspace__P__dir => dspace.dir) + # __D__ => "-" (e.g. google__D__metadata => google-metadata) + # dspace.dir, dspace.server.url, dspace.ui.url and dspace.name + dspace__P__dir: /dspace + dspace__P__server__P__url: http://localhost:8080/server + dspace__P__ui__P__url: http://localhost:4000 + dspace__P__name: 'DSpace Started with Docker Compose' + # db.url: Ensure we are using the 'dspacedb' image for our database + db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + # solr.server: Ensure we are using the 'dspacesolr' image for Solr + solr__P__server: http://dspacesolr:8983/solr + # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests + # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. + proxies__P__trusted__P__ipranges: '172.23.0' image: dspace/dspace:dspace-7_x-test depends_on: - dspacedb @@ -29,7 +51,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - - "./local.cfg:/dspace/config/local.cfg" # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat @@ -62,7 +83,7 @@ services: dspacesolr: container_name: dspacesolr # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.8 + image: solr:8.11-slim # Needs main 'dspace' container to start first to guarantee access to solr_configs depends_on: - dspace diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7c5c326959..1387b1de39 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,11 +16,15 @@ services: dspace-angular: container_name: dspace-angular environment: - DSPACE_HOST: dspace-angular - DSPACE_NAMESPACE: / - DSPACE_PORT: '4000' - DSPACE_SSL: "false" - image: dspace/dspace-angular:latest + DSPACE_UI_SSL: 'false' + DSPACE_UI_HOST: dspace-angular + DSPACE_UI_PORT: '4000' + DSPACE_UI_NAMESPACE: / + DSPACE_REST_SSL: 'false' + DSPACE_REST_HOST: localhost + DSPACE_REST_PORT: 8080 + DSPACE_REST_NAMESPACE: /server + image: dspace/dspace-angular:dspace-7_x build: context: .. dockerfile: Dockerfile @@ -33,5 +37,3 @@ services: target: 9876 stdin_open: true tty: true - volumes: - - ./environment.dev.ts:/app/src/environments/environment.dev.ts diff --git a/docker/environment.dev.ts b/docker/environment.dev.ts deleted file mode 100644 index 0e603ef11d..0000000000 --- a/docker/environment.dev.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ -// This file is based on environment.template.ts provided by Angular UI -export const environment = { - // Default to using the local REST API (running in Docker) - rest: { - ssl: false, - host: 'localhost', - port: 8080, - // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/server' - } -}; diff --git a/docker/local.cfg b/docker/local.cfg deleted file mode 100644 index a511c25789..0000000000 --- a/docker/local.cfg +++ /dev/null @@ -1,6 +0,0 @@ -dspace.dir=/dspace -db.url=jdbc:postgresql://dspacedb:5432/dspace -dspace.server.url=http://localhost:8080/server -dspace.ui.url=http://localhost:4000 -dspace.name=DSpace Started with Docker Compose -solr.server=http://dspacesolr:8983/solr diff --git a/docs/Configuration.md b/docs/Configuration.md index f4fff1166c..62fa444cc0 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,26 +1,30 @@ # Configuration -Default configuration file is located in `src/environments/` folder. All configuration options should be listed in the default configuration file `src/environments/environment.common.ts`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. +Default configuration file is located at `config/config.yml`. All configuration options should be listed in the default typescript file `src/config/default-app-config.ts`. Please do not change this file directly! To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point. -- Create a new `environment.dev.ts` file in `src/environments/` for `development` environment; -- Create a new `environment.prod.ts` file in `src/environments/` for `production` environment; +- Create a new `config.(dev or development).yml` file in `config/` for `development` environment; +- Create a new `config.(prod or production).yml` file in `config/` for `production` environment; -Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below. +Alternatively, create a desired app config file at an external location and set the path as environment variable `DSPACE_APP_CONFIG_PATH`. + +e.g. +``` +DSPACE_APP_CONFIG_PATH=/usr/local/dspace/config/config.yml +``` + +Configuration options can be overridden by setting environment variables. ## Nodejs server When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`. To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above): -``` -export const environment = { - // Angular UI settings. - ui: { - ssl: false, - host: 'localhost', - port: 4000, - nameSpace: '/' - } -}; + +```yaml +ui: + ssl: false + host: localhost + port: 4000 + nameSpace: / ``` Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: @@ -30,21 +34,24 @@ Alternately you can set the following environment variables. If any of these are DSPACE_PORT=4000 DSPACE_NAMESPACE=/ ``` +or +``` + DSPACE_UI_SSL=true + DSPACE_UI_HOST=localhost + DSPACE_UI_PORT=4000 + DSPACE_UI_NAMESPACE=/ +``` ## DSpace's REST endpoint dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options: -``` -export const environment = { - // The REST API server settings. - rest: { - ssl: true, - host: 'api7.dspace.org', - port: 443, - // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/server' - } -}; +```yaml +rest: + ssl: true + host: api7.dspace.org + port: 443 + nameSpace: /server +} ``` Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: @@ -55,6 +62,21 @@ Alternately you can set the following environment variables. If any of these are DSPACE_REST_NAMESPACE=/server ``` +## Environment variable naming convention + +Settings can be set using the following convention for naming the environment variables: + +1. replace all `.` with `_` +2. convert all characters to upper case +3. prefix with `DSPACE_` + +e.g. + +``` +cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT +auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE +``` + ## Supporting analytics services other than Google Analytics This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics. diff --git a/mock-nodemon.json b/mock-nodemon.json deleted file mode 100644 index 18fc86bd9d..0000000000 --- a/mock-nodemon.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "watch": ["src/environments/mock-environment.ts"], - "ext": "ts", - "exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts" -} diff --git a/nodemon.json b/nodemon.json index 39e9d9aa5b..dec8d9724c 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,6 +1,6 @@ { - "watch": ["src/environments"], - "ext": "ts", - "ignore": ["src/environments/environment.ts", "src/environments/mock-environment.ts"], - "exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev" + "watch": [ + "config" + ], + "ext": "json" } diff --git a/package.json b/package.json index 236845da1a..278afdf6c3 100644 --- a/package.json +++ b/package.json @@ -3,53 +3,40 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "config:dev": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev", - "config:prod": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --prod", - "config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts", - "config:test:watch": "nodemon --config mock-nodemon.json", - "config:dev:watch": "nodemon", - "config:check:rest": "yarn run config:prod && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", - "config:dev:check:rest": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", - "prestart:dev": "yarn run config:dev", - "prebuild": "yarn run config:dev", - "pretest": "yarn run config:test", - "pretest:watch": "yarn run config:test", - "pretest:headless": "yarn run config:test", - "prebuild:prod": "yarn run config:prod", - "pree2e": "yarn run config:prod", + "config:watch": "nodemon", + "test:rest": "ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts", "start": "yarn run start:prod", - "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", - "start:dev": "npm-run-all --parallel config:dev:watch serve", - "start:prod": "yarn run build:prod && yarn run serve:ssr", + "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", + "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod", + "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", + "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build", "build:stats": "ng build --stats-json", "build:prod": "yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", - "test:watch": "npm-run-all --parallel config:test:watch test", - "test": "ng test --sourceMap=true --watch=true", - "test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", + "test": "ng test --sourceMap=true --watch=false --configuration test", + "test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"", + "test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "lint": "ng lint", "lint-fix": "ng lint --fix=true", "e2e": "ng e2e", - "serve:ssr": "node dist/server/main", + "clean:dev:config": "rimraf src/assets/config.json", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", "clean:doc": "rimraf doc", "clean:log": "rimraf *.log*", "clean:json": "rimraf *.records.json", - "clean:bld": "rimraf build", "clean:node": "rimraf node_modules", - "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", - "clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node", - "clean:env": "rimraf src/environments/environment.ts", - "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts", + "clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json", + "clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:node", + "sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts", "build:mirador": "webpack --config webpack/webpack.mirador.config.ts", - "merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", - "postinstall": "ngcc", + "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "cypress:open": "cypress open", - "cypress:run": "cypress run" + "cypress:run": "cypress run", + "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts" }, "browser": { "fs": false, @@ -74,7 +61,6 @@ "@angular/platform-browser-dynamic": "~11.2.14", "@angular/platform-server": "~11.2.14", "@angular/router": "~11.2.14", - "@angularclass/bootloader": "1.0.1", "@kolkov/ngx-gallery": "^1.2.3", "@ng-bootstrap/ng-bootstrap": "9.1.3", "@ng-dynamic-forms/core": "^13.0.0", @@ -102,17 +88,18 @@ "file-saver": "^2.0.5", "filesize": "^6.1.0", "font-awesome": "4.7.0", - "https": "1.0.0", "http-proxy-middleware": "^1.0.5", + "https": "1.0.0", "js-cookie": "2.2.1", + "js-yaml": "^4.1.0", "json5": "^2.1.3", "jsonschema": "1.4.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.10", "lodash": "^4.17.21", - "mirador": "^3.0.0", + "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", - "mirador-share-plugin": "^0.10.0", + "mirador-share-plugin": "^0.11.0", "moment": "^2.29.1", "morgan": "^1.10.0", "ng-mocks": "11.11.2", @@ -125,8 +112,6 @@ "nouislider": "^14.6.3", "pem": "1.14.4", "postcss-cli": "^8.3.0", - "react": "^16.14.0", - "react-dom": "^16.14.0", "reflect-metadata": "^0.1.13", "rxjs": "^6.6.3", "sortablejs": "1.13.0", @@ -159,6 +144,7 @@ "codelyzer": "^6.0.0", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^6.4.1", + "cross-env": "^7.0.3", "css-loader": "3.4.0", "cssnano": "^4.1.10", "cypress": "8.6.0", @@ -178,8 +164,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "nodemon": "^2.0.2", - "npm-run-all": "^4.1.5", + "nodemon": "^2.0.15", "optimize-css-assets-webpack-plugin": "^5.0.4", "postcss-apply": "0.11.0", "postcss-import": "^12.0.1", @@ -189,6 +174,8 @@ "protractor": "^7.0.0", "protractor-istanbul-plugin": "2.0.0", "raw-loader": "0.5.1", + "react": "^16.14.0", + "react-dom": "^16.14.0", "rimraf": "^3.0.2", "rxjs-spy": "^7.5.3", "sass-resources-loader": "^2.1.1", @@ -204,4 +191,4 @@ "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" } -} \ No newline at end of file +} diff --git a/scripts/env-to-yaml.ts b/scripts/env-to-yaml.ts new file mode 100644 index 0000000000..edcdfd90b4 --- /dev/null +++ b/scripts/env-to-yaml.ts @@ -0,0 +1,39 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { join } from 'path'; + +/** + * Script to help convert previous version environment.*.ts to yaml. + * + * Usage (see package.json): + * + * yarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file) * + */ + +const args = process.argv.slice(2); +if (args[0] === undefined) { + console.log(`Usage:\n\tyarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file)\n`); + process.exit(0); +} + +const envFullPath = join(process.cwd(), args[0]); + +if (!fs.existsSync(envFullPath)) { + console.error(`Error:\n${envFullPath} does not exist\n`); + process.exit(1); +} + +try { + const env = require(envFullPath); + + const config = yaml.dump(env); + if (args[1]) { + const ymlFullPath = join(process.cwd(), args[1]); + fs.writeFileSync(ymlFullPath, config); + } else { + console.log(config); + } +} catch (e) { + console.error(e); +} + diff --git a/scripts/serve.ts b/scripts/serve.ts index c69f8e8a21..bf5506b8bd 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -1,11 +1,14 @@ -import { environment } from '../src/environments/environment'; - import * as child from 'child_process'; +import { AppConfig } from '../src/config/app-config.interface'; +import { buildAppConfig } from '../src/config/config.server'; + +const appConfig: AppConfig = buildAppConfig(); + /** - * Calls `ng serve` with the following arguments configured for the UI in the environment file: host, port, nameSpace, ssl + * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl */ child.spawn( - `ng serve --host ${environment.ui.host} --port ${environment.ui.port} --servePath ${environment.ui.nameSpace} --ssl ${environment.ui.ssl}`, - { stdio:'inherit', shell: true } + `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, + { stdio: 'inherit', shell: true } ); diff --git a/scripts/set-env.ts b/scripts/set-env.ts deleted file mode 100644 index b3516ae68f..0000000000 --- a/scripts/set-env.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { writeFile } from 'fs'; -import { environment as commonEnv } from '../src/environments/environment.common'; -import { GlobalConfig } from '../src/config/global-config.interface'; -import { ServerConfig } from '../src/config/server-config.interface'; -import { hasValue } from '../src/app/shared/empty.util'; - -// Configure Angular `environment.ts` file path -const targetPath = './src/environments/environment.ts'; -// Load node modules -const colors = require('colors'); -require('dotenv').config(); -const merge = require('deepmerge'); -const mergeOptions = { arrayMerge: (destinationArray, sourceArray, options) => sourceArray }; -const environment = process.argv[2]; -let environmentFilePath; -let production = false; - -switch (environment) { - case '--prod': - case '--production': - production = true; - console.log(`Building ${colors.red.bold(`production`)} environment`); - environmentFilePath = '../src/environments/environment.prod.ts'; - break; - case '--test': - console.log(`Building ${colors.blue.bold(`test`)} environment`); - environmentFilePath = '../src/environments/environment.test.ts'; - break; - default: - console.log(`Building ${colors.green.bold(`development`)} environment`); - environmentFilePath = '../src/environments/environment.dev.ts'; -} - -const processEnv = { - ui: createServerConfig( - process.env.DSPACE_HOST, - process.env.DSPACE_PORT, - process.env.DSPACE_NAMESPACE, - process.env.DSPACE_SSL), - rest: createServerConfig( - process.env.DSPACE_REST_HOST, - process.env.DSPACE_REST_PORT, - process.env.DSPACE_REST_NAMESPACE, - process.env.DSPACE_REST_SSL) -} as GlobalConfig; - -import(environmentFilePath) - .then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv], mergeOptions))) - .catch(() => { - console.log(colors.yellow.bold(`No specific environment file found for ` + environment)); - generateEnvironmentFile(merge(commonEnv, processEnv, mergeOptions)) - }); - -function generateEnvironmentFile(file: GlobalConfig): void { - file.production = production; - buildBaseUrls(file); - const contents = `export const environment = ` + JSON.stringify(file); - writeFile(targetPath, contents, (err) => { - if (err) { - throw console.error(err); - } else { - console.log(`Angular ${colors.bold('environment.ts')} file generated correctly at ${colors.bold(targetPath)} \n`); - } - }); -} - -// allow to override a few important options by environment variables -function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig { - const result = {} as any; - if (hasValue(host)) { - result.host = host; - } - - if (hasValue(nameSpace)) { - result.nameSpace = nameSpace; - } - - if (hasValue(port)) { - result.port = Number(port); - } - - if (hasValue(ssl)) { - result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false; - } - - return result; -} - -function buildBaseUrls(config: GlobalConfig): void { - for (const key in config) { - if (config.hasOwnProperty(key) && config[key].host) { - config[key].baseUrl = [ - getProtocol(config[key].ssl), - getHost(config[key].host), - getPort(config[key].port), - getNameSpace(config[key].nameSpace) - ].join(''); - } - } -} - -function getProtocol(ssl: boolean): string { - return ssl ? 'https://' : 'http://'; -} - -function getHost(host: string): string { - return host; -} - -function getPort(port: number): string { - return port ? (port !== 80 && port !== 443) ? ':' + port : '' : ''; -} - -function getNameSpace(nameSpace: string): string { - return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : ''; -} diff --git a/scripts/set-mock-env.ts b/scripts/set-mock-env.ts deleted file mode 100644 index 5271432896..0000000000 --- a/scripts/set-mock-env.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { copyFile } from 'fs'; - -// Configure Angular `environment.ts` file path -const sourcePath = './src/environments/mock-environment.ts'; -const targetPath = './src/environments/environment.ts'; - -// destination.txt will be created or overwritten by default. -copyFile(sourcePath, targetPath, (err) => { - if (err) throw err; - console.log(sourcePath + ' was copied to ' + targetPath); -}); diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts index b12a9929c2..aa3b64f62b 100644 --- a/scripts/test-rest.ts +++ b/scripts/test-rest.ts @@ -1,21 +1,25 @@ import * as http from 'http'; import * as https from 'https'; -import { environment } from '../src/environments/environment'; + +import { AppConfig } from '../src/config/app-config.interface'; +import { buildAppConfig } from '../src/config/config.server'; + +const appConfig: AppConfig = buildAppConfig(); /** - * Script to test the connection with the configured REST API (in the 'rest' settings of your environment.*.ts) + * Script to test the connection with the configured REST API (in the 'rest' settings of your config.*.yaml) * * This script is useful to test for any Node.js connection issues with your REST API. * - * Usage (see package.json): yarn test:rest-api + * Usage (see package.json): yarn test:rest */ // Get root URL of configured REST API -const restUrl = environment.rest.baseUrl + '/api'; +const restUrl = appConfig.rest.baseUrl + '/api'; console.log(`...Testing connection to REST API at ${restUrl}...\n`); // If SSL enabled, test via HTTPS, else via HTTP -if (environment.rest.ssl) { +if (appConfig.rest.ssl) { const req = https.request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); res.on('data', (data) => { @@ -55,7 +59,7 @@ function checkJSONResponse(responseData: any): any { console.log(`\t"dspaceVersion" = ${parsedData.dspaceVersion}`); console.log(`\t"dspaceUI" = ${parsedData.dspaceUI}`); console.log(`\t"dspaceServer" = ${parsedData.dspaceServer}`); - console.log(`\t"dspaceServer" property matches UI's "rest" config? ${(parsedData.dspaceServer === environment.rest.baseUrl)}`); + console.log(`\t"dspaceServer" property matches UI's "rest" config? ${(parsedData.dspaceServer === appConfig.rest.baseUrl)}`); // Check for "authn" and "sites" in "_links" section as they should always exist (even if no data)! const linksFound: string[] = Object.keys(parsedData._links); console.log(`\tDoes "/api" endpoint have HAL links ("_links" section)? ${linksFound.includes('authn') && linksFound.includes('sites')}`); diff --git a/server.ts b/server.ts index c00bdb5ef5..da3b877bc1 100644 --- a/server.ts +++ b/server.ts @@ -19,27 +19,34 @@ import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import 'rxjs'; -import * as fs from 'fs'; import * as pem from 'pem'; import * as https from 'https'; import * as morgan from 'morgan'; import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as compression from 'compression'; + +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { APP_BASE_HREF } from '@angular/common'; import { enableProdMode } from '@angular/core'; -import { existsSync } from 'fs'; + import { ngExpressEngine } from '@nguniversal/express-engine'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; + import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { hasValue, hasNoValue } from './src/app/shared/empty.util'; -import { APP_BASE_HREF } from '@angular/common'; + import { UIServerConfig } from './src/config/ui-server-config.interface'; import { ServerAppModule } from './src/main.server'; +import { buildAppConfig } from './src/config/config.server'; +import { AppConfig, APP_CONFIG } from './src/config/app-config.interface'; +import { extendEnvironmentWithAppConfig } from './src/config/config.util'; + /* * Set path for the browser application's dist folder */ @@ -51,6 +58,11 @@ const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : ' const cookieParser = require('cookie-parser'); +const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); + +// extend environment with app config for server +extendEnvironmentWithAppConfig(environment, appConfig); + // The Express app is exported so that it can be used by serverless Functions. export function app() { @@ -100,7 +112,11 @@ export function app() { provide: RESPONSE, useValue: (options as any).req.res, }, - ], + { + provide: APP_CONFIG, + useValue: environment + } + ] })(_, (options as any), callback) ); @@ -237,14 +253,14 @@ function start() { if (environment.ui.ssl) { let serviceKey; try { - serviceKey = fs.readFileSync('./config/ssl/key.pem'); + serviceKey = readFileSync('./config/ssl/key.pem'); } catch (e) { console.warn('Service key not found at ./config/ssl/key.pem'); } let certificate; try { - certificate = fs.readFileSync('./config/ssl/cert.pem'); + certificate = readFileSync('./config/ssl/cert.pem'); } catch (e) { console.warn('Certificate not found at ./config/ssl/key.pem'); } diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts index 0e872458bd..891238bbed 100644 --- a/src/app/access-control/access-control.module.ts +++ b/src/app/access-control/access-control.module.ts @@ -9,13 +9,15 @@ import { GroupFormComponent } from './group-registry/group-form/group-form.compo import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component'; import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; +import { FormModule } from '../shared/form/form.module'; @NgModule({ imports: [ CommonModule, SharedModule, RouterModule, - AccessControlRoutingModule + AccessControlRoutingModule, + FormModule ], declarations: [ EPeopleRegistryComponent, diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 45326c1abc..41ae67423c 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -19,7 +19,7 @@ class="btn btn-outline-secondary"> {{messagePrefix + '.return' | translate}}
-
@@ -36,9 +36,13 @@ + +
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
+ + { let component: EPersonFormComponent; @@ -42,6 +42,7 @@ describe('EPersonFormComponent', () => { let authService: AuthServiceStub; let authorizationService: AuthorizationDataService; let groupsDataService: GroupDataService; + let epersonRegistrationService: EpersonRegistrationService; let paginationService; @@ -199,12 +200,18 @@ describe('EPersonFormComponent', () => { { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: PaginationService, useValue: paginationService }, - { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) } + { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + registerEmail: createSuccessfulRemoteDataObject$(null) + }); + beforeEach(() => { fixture = TestBed.createComponent(EPersonFormComponent); component = fixture.componentInstance; @@ -514,4 +521,23 @@ describe('EPersonFormComponent', () => { expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson); }); }); + + describe('Reset Password', () => { + let ePersonId; + let ePersonEmail; + + beforeEach(() => { + ePersonId = 'testEPersonId'; + ePersonEmail = 'person.email@4science.it'; + component.epersonInitial = Object.assign(new EPerson(), { + id: ePersonId, + email: ePersonEmail + }); + component.resetPassword(); + }); + + it('should call epersonRegistrationService.registerEmail', () => { + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail); + }); + }); }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 723939df77..05fc3189d0 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -34,6 +34,8 @@ import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; +import { Registration } from '../../../core/shared/registration.model'; +import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; @Component({ selector: 'ds-eperson-form', @@ -121,7 +123,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Observable whether or not the admin is allowed to reset the EPerson's password * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false) */ - canReset$: Observable = observableOf(false); + canReset$: Observable; /** * Observable whether or not the admin is allowed to delete the EPerson @@ -167,17 +169,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ emailValueChangeSubscribe: Subscription; - constructor(protected changeDetectorRef: ChangeDetectorRef, - public epersonService: EPersonDataService, - public groupsDataService: GroupDataService, - private formBuilderService: FormBuilderService, - private translateService: TranslateService, - private notificationsService: NotificationsService, - private authService: AuthService, - private authorizationService: AuthorizationDataService, - private modalService: NgbModal, - private paginationService: PaginationService, - public requestService: RequestService) { + constructor( + protected changeDetectorRef: ChangeDetectorRef, + public epersonService: EPersonDataService, + public groupsDataService: GroupDataService, + private formBuilderService: FormBuilderService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private authService: AuthService, + private authorizationService: AuthorizationDataService, + private modalService: NgbModal, + private paginationService: PaginationService, + public requestService: RequestService, + private epersonRegistrationService: EpersonRegistrationService, + ) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.epersonInitial = eperson; if (hasValue(eperson)) { @@ -310,6 +315,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.canDelete$ = activeEPerson$.pipe( switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) ); + this.canReset$ = observableOf(true); }); } @@ -479,6 +485,26 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.isImpersonated = false; } + /** + * Sends an email to current eperson address with the information + * to reset password + */ + resetPassword() { + if (hasValue(this.epersonInitial.email)) { + this.epersonRegistrationService.registerEmail(this.epersonInitial.email).pipe(getFirstCompletedRemoteData()) + .subscribe((response: RemoteData) => { + if (response.hasSucceeded) { + this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), + this.translateService.get('forgot-email.form.success.content', {email: this.epersonInitial.email})); + } else { + this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), + this.translateService.get('forgot-email.form.error.content', {email: this.epersonInitial.email})); + } + } + ); + } + } + /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/admin/admin-registries/admin-registries.module.ts b/src/app/admin/admin-registries/admin-registries.module.ts index 5c82bb2ec9..65f7b61419 100644 --- a/src/app/admin/admin-registries/admin-registries.module.ts +++ b/src/app/admin/admin-registries/admin-registries.module.ts @@ -8,6 +8,7 @@ import { SharedModule } from '../../shared/shared.module'; import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component'; import { MetadataFieldFormComponent } from './metadata-schema/metadata-field-form/metadata-field-form.component'; import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.module'; +import { FormModule } from '../../shared/form/form.module'; @NgModule({ imports: [ @@ -15,7 +16,8 @@ import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.mo SharedModule, RouterModule, BitstreamFormatsModule, - AdminRegistriesRoutingModule + AdminRegistriesRoutingModule, + FormModule ], declarations: [ MetadataRegistryComponent, diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.module.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.module.ts index 1667a07c0b..afbe35a1f6 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.module.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.module.ts @@ -7,13 +7,15 @@ import { FormatFormComponent } from './format-form/format-form.component'; import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component'; import { BitstreamFormatsRoutingModule } from './bitstream-formats-routing.module'; import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component'; +import { FormModule } from '../../../shared/form/form.module'; @NgModule({ imports: [ CommonModule, SharedModule, RouterModule, - BitstreamFormatsRoutingModule + BitstreamFormatsRoutingModule, + FormModule ], declarations: [ BitstreamFormatsComponent, diff --git a/src/app/admin/admin-search-page/admin-search.module.ts b/src/app/admin/admin-search-page/admin-search.module.ts index 0b3b7df9bb..353d6dd498 100644 --- a/src/app/admin/admin-search-page/admin-search.module.ts +++ b/src/app/admin/admin-search-page/admin-search.module.ts @@ -10,6 +10,7 @@ import { CollectionAdminSearchResultGridElementComponent } from './admin-search- import { ItemAdminSearchResultActionsComponent } from './admin-search-results/item-admin-search-result-actions.component'; import { JournalEntitiesModule } from '../../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../../entity-groups/research-entities/research-entities.module'; +import { SearchModule } from '../../shared/search/search.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -24,6 +25,7 @@ const ENTRY_COMPONENTS = [ @NgModule({ imports: [ + SearchModule, SharedModule.withEntryComponents(), JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents() @@ -36,7 +38,7 @@ const ENTRY_COMPONENTS = [ export class AdminSearchModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 948d7d86bc..65026c1504 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -18,6 +18,8 @@ import { ActivatedRoute } from '@angular/router'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import createSpy = jasmine.createSpy; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { Item } from '../../core/shared/item.model'; describe('AdminSidebarComponent', () => { let comp: AdminSidebarComponent; @@ -26,6 +28,28 @@ describe('AdminSidebarComponent', () => { let authorizationService: AuthorizationDataService; let scriptService; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + _links: { + self: { + href: 'https://localhost:8000/items/fake-id' + } + } + }); + + + const routeStub = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject(mockItem) + }), + children: [] + }; + + beforeEach(waitForAsync(() => { authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: observableOf(true) @@ -42,6 +66,7 @@ describe('AdminSidebarComponent', () => { { provide: ActivatedRoute, useValue: {} }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: ScriptDataService, useValue: scriptService }, + { provide: ActivatedRoute, useValue: routeStub }, { provide: NgbModal, useValue: { open: () => {/*comment*/ @@ -229,19 +254,19 @@ describe('AdminSidebarComponent', () => { it('should contain site admin section', () => { expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: true, + id: 'admin_search', visible: true, })); expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: true, + id: 'registries', visible: true, })); expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: true, + parentID: 'registries', visible: true, })); expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: true, + id: 'curation_tasks', visible: true, })); expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, + id: 'workflow', visible: true, })); }); }); @@ -259,7 +284,7 @@ describe('AdminSidebarComponent', () => { it('should show edit_community', () => { expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: true, + id: 'edit_community', visible: true, })); }); }); @@ -277,7 +302,7 @@ describe('AdminSidebarComponent', () => { it('should show edit_collection', () => { expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: true, + id: 'edit_collection', visible: true, })); }); }); @@ -295,10 +320,10 @@ describe('AdminSidebarComponent', () => { it('should show access control section', () => { expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: true, + id: 'access_control', visible: true, })); expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: true, + parentID: 'access_control', visible: true, })); }); }); diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index f0d583744c..c81b2e6e93 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -21,6 +21,7 @@ import { MenuService } from '../../shared/menu/menu.service'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { Router, ActivatedRoute } from '@angular/router'; /** * Component representing the admin sidebar @@ -63,14 +64,15 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { inFocus$: BehaviorSubject; constructor(protected menuService: MenuService, - protected injector: Injector, - private variableService: CSSVariableService, - private authService: AuthService, - private modalService: NgbModal, - private authorizationService: AuthorizationDataService, - private scriptDataService: ScriptDataService, + protected injector: Injector, + private variableService: CSSVariableService, + private authService: AuthService, + private modalService: NgbModal, + public authorizationService: AuthorizationDataService, + private scriptDataService: ScriptDataService, + public route: ActivatedRoute ) { - super(menuService, injector); + super(menuService, injector, authorizationService, route); this.inFocus$ = new BehaviorSubject(false); } @@ -144,7 +146,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { type: MenuItemType.TEXT, text: 'menu.section.new' } as TextMenuItemModel, - icon: 'plus', + icon: 'plus', index: 0 }, { diff --git a/src/app/admin/admin-workflow-page/admin-workflow.module.ts b/src/app/admin/admin-workflow-page/admin-workflow.module.ts index 4715ae16f4..85e8f00a46 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow.module.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow.module.ts @@ -5,6 +5,7 @@ import { WorkflowItemSearchResultAdminWorkflowGridElementComponent } from './adm import { WorkflowItemAdminWorkflowActionsComponent } from './admin-workflow-search-results/workflow-item-admin-workflow-actions.component'; import { WorkflowItemSearchResultAdminWorkflowListElementComponent } from './admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component'; import { AdminWorkflowPageComponent } from './admin-workflow-page.component'; +import { SearchModule } from '../../shared/search/search.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -14,6 +15,7 @@ const ENTRY_COMPONENTS = [ @NgModule({ imports: [ + SearchModule, SharedModule.withEntryComponents() ], declarations: [ @@ -28,7 +30,7 @@ const ENTRY_COMPONENTS = [ export class AdminWorkflowModuleModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { diff --git a/src/app/admin/admin.module.ts b/src/app/admin/admin.module.ts index 25cdd67dcf..b28a0cf89e 100644 --- a/src/app/admin/admin.module.ts +++ b/src/app/admin/admin.module.ts @@ -24,7 +24,7 @@ const ENTRY_COMPONENTS = [ AccessControlModule, AdminSearchModule.withEntryComponents(), AdminWorkflowModuleModule.withEntryComponents(), - SharedModule, + SharedModule ], declarations: [ AdminCurationTasksComponent, @@ -34,7 +34,7 @@ const ENTRY_COMPONENTS = [ export class AdminModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 26d9a5dc0d..04d2c55bdd 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -203,7 +203,6 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu ]} ],{ onSameUrlNavigation: 'reload', - relativeLinkResolution: 'legacy' }) ], exports: [RouterModule], diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 3f2dc45ce7..a892e34a5a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -36,6 +36,8 @@ import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { ThemeService } from './shared/theme-support/theme.service'; import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { APP_CONFIG } from '../config/app-config.interface'; +import { environment } from '../environments/environment'; let comp: AppComponent; let fixture: ComponentFixture; @@ -83,6 +85,7 @@ describe('App component', () => { { provide: LocaleService, useValue: getMockLocaleService() }, { provide: ThemeService, useValue: getMockThemeService() }, { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, + { provide: APP_CONFIG, useValue: environment }, provideMockStore({ initialState }), AppComponent, RouteService @@ -171,7 +174,8 @@ describe('App component', () => { TestBed.configureTestingModule(getDefaultTestBedConf()); TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); document = TestBed.inject(DOCUMENT); - headSpy = jasmine.createSpyObj('head', ['appendChild']); + headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']); + headSpy.getElementsByClassName.and.returnValue([]); spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6f06a84144..669411d9aa 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,5 @@ import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -17,8 +18,10 @@ import { Router, } from '@angular/router'; +import { isEqual } from 'lodash'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -31,21 +34,20 @@ import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { HostWindowService } from './shared/host-window.service'; -import { ThemeConfig } from '../config/theme.model'; +import { HeadTagConfig, ThemeConfig } from '../config/theme.model'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; -import { hasValue, isNotEmpty } from './shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util'; import { KlaroService } from './shared/cookies/klaro.service'; import { GoogleAnalyticsService } from './statistics/google-analytics.service'; -import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { ThemeService } from './shared/theme-support/theme.service'; import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; -import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { getDefaultThemeConfig } from '../config/config.util'; +import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; @Component({ selector: 'ds-app', @@ -59,7 +61,7 @@ export class AppComponent implements OnInit, AfterViewInit { collapsedSidebarWidth: Observable; totalSidebarWidth: Observable; theme: Observable = of({} as any); - notificationOptions = environment.notifications; + notificationOptions; models; /** @@ -88,6 +90,7 @@ export class AppComponent implements OnInit, AfterViewInit { @Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(DOCUMENT) private document: any, @Inject(PLATFORM_ID) private platformId: any, + @Inject(APP_CONFIG) private appConfig: AppConfig, private themeService: ThemeService, private translate: TranslateService, private store: Store, @@ -106,6 +109,12 @@ export class AppComponent implements OnInit, AfterViewInit { @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { + if (!isEqual(environment, this.appConfig)) { + throw new Error('environment does not match app config!'); + } + + this.notificationOptions = environment.notifications; + /* Use models object so all decorators are actually called */ this.models = models; @@ -115,11 +124,14 @@ export class AppComponent implements OnInit, AfterViewInit { this.isThemeCSSLoading$.next(true); } if (hasValue(themeName)) { - this.setThemeCss(themeName); - } else if (hasValue(DEFAULT_THEME_CONFIG)) { - this.setThemeCss(DEFAULT_THEME_CONFIG.name); + this.loadGlobalThemeConfig(themeName); } else { - this.setThemeCss(BASE_THEME_NAME); + const defaultThemeConfig = getDefaultThemeConfig(); + if (hasValue(defaultThemeConfig)) { + this.loadGlobalThemeConfig(defaultThemeConfig.name); + } else { + this.loadGlobalThemeConfig(BASE_THEME_NAME); + } } }); @@ -233,6 +245,11 @@ export class AppComponent implements OnInit, AfterViewInit { } } + private loadGlobalThemeConfig(themeName: string): void { + this.setThemeCss(themeName); + this.setHeadTags(themeName); + } + /** * Update the theme css file in * @@ -241,9 +258,13 @@ export class AppComponent implements OnInit, AfterViewInit { */ private setThemeCss(themeName: string): void { const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + // Array.from to ensure we end up with an array, not an HTMLCollection, which would be // automatically updated if we add nodes later - const currentThemeLinks = Array.from(this.document.getElementsByClassName('theme-css')); + const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css')); const link = this.document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); @@ -265,6 +286,78 @@ export class AppComponent implements OnInit, AfterViewInit { head.appendChild(link); } + private setHeadTags(themeName: string): void { + const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + + // clear head tags + const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag')); + if (hasValue(currentHeadTags)) { + currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove()); + } + + // create new head tags (not yet added to DOM) + const headTagFragment = this.document.createDocumentFragment(); + this.createHeadTags(themeName) + .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); + + // add new head tags to DOM + head.appendChild(headTagFragment); + } + + private createHeadTags(themeName: string): HTMLElement[] { + const themeConfig = this.themeService.getThemeConfigFor(themeName); + const headTagConfigs = themeConfig?.headTags; + + if (hasNoValue(headTagConfigs)) { + const parentThemeName = themeConfig?.extends; + if (hasValue(parentThemeName)) { + // inherit the head tags of the parent theme + return this.createHeadTags(parentThemeName); + } + const defaultThemeConfig = getDefaultThemeConfig(); + const defaultThemeName = defaultThemeConfig.name; + if ( + hasNoValue(defaultThemeName) || + themeName === defaultThemeName || + themeName === BASE_THEME_NAME + ) { + // last resort, use fallback favicon.ico + return [ + this.createHeadTag({ + 'tagName': 'link', + 'attributes': { + 'rel': 'icon', + 'href': 'assets/images/favicon.ico', + 'sizes': 'any', + } + }) + ]; + } + + // inherit the head tags of the default theme + return this.createHeadTags(defaultThemeConfig.name); + } + + return headTagConfigs.map(this.createHeadTag.bind(this)); + } + + private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement { + const tag = this.document.createElement(headTagConfig.tagName); + + if (hasValue(headTagConfig.attributes)) { + Object.entries(headTagConfig.attributes) + .forEach(([key, value]) => tag.setAttribute(key, value)); + } + + // 'class' attribute should always be 'theme-head-tag' for removal + tag.setAttribute('class', 'theme-head-tag'); + + return tag; + } + private trackIdleModal() { const isIdle$ = this.authService.isUserIdle(); const isAuthenticated$ = this.authService.isAuthenticated(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 341d55ea38..32c3c78348 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,6 +1,8 @@ import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; @@ -37,7 +39,6 @@ import { NotificationsBoardComponent } from './shared/notifications/notification import { SharedModule } from './shared/shared.module'; import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { environment } from '../environments/environment'; -import { BrowserModule } from '@angular/platform-browser'; import { ForbiddenComponent } from './forbidden/forbidden.component'; import { AuthInterceptor } from './core/auth/auth.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor'; @@ -54,16 +55,18 @@ import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.com import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { UUIDService } from './core/shared/uuid.service'; -import { CookieService } from './core/services/cookie.service'; -import { AbstractControl } from '@angular/forms'; +import { AppConfig, APP_CONFIG } from '../config/app-config.interface'; -export function getBase() { - return environment.ui.nameSpace; +export function getConfig() { + return environment; } -export function getMetaReducers(): MetaReducer[] { - return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; +export function getBase(appConfig: AppConfig) { + return appConfig.ui.nameSpace; +} + +export function getMetaReducers(appConfig: AppConfig): MetaReducer[] { + return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; } /** @@ -71,7 +74,7 @@ export function getMetaReducers(): MetaReducer[] { */ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = (control: AbstractControl, model: any, hasFocus: boolean) => { - return (control.touched && !hasFocus) || (control.errors ?.emailTaken && hasFocus); + return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus); }; const IMPORTS = [ @@ -98,13 +101,19 @@ IMPORTS.push( ); const PROVIDERS = [ + { + provide: APP_CONFIG, + useFactory: getConfig + }, { provide: APP_BASE_HREF, - useFactory: (getBase) + useFactory: getBase, + deps: [APP_CONFIG] }, { provide: USER_PROVIDED_META_REDUCERS, useFactory: getMetaReducers, + deps: [APP_CONFIG] }, { provide: RouterStateSerializer, @@ -114,7 +123,7 @@ const PROVIDERS = [ // Check the authentication token when the app initializes { provide: APP_INITIALIZER, - useFactory: (store: Store, ) => { + useFactory: (store: Store,) => { return () => store.dispatch(new CheckAuthenticationTokenAction()); }, deps: [Store], @@ -144,21 +153,6 @@ const PROVIDERS = [ useClass: LogInterceptor, multi: true }, - // insert the unique id of the user that is using the application utilizing cookies - { - provide: APP_INITIALIZER, - useFactory: (cookieService: CookieService, uuidService: UUIDService) => { - const correlationId = cookieService.get('CORRELATION-ID'); - - // Check if cookie exists, if don't, set it with unique id - if (!correlationId) { - cookieService.set('CORRELATION-ID', uuidService.generate()); - } - return () => true; - }, - multi: true, - deps: [CookieService, UUIDService] - }, { provide: DYNAMIC_ERROR_MESSAGES_MATCHER, useValue: ValidateEmailErrorStateMatcher @@ -195,7 +189,7 @@ const EXPORTS = [ @NgModule({ imports: [ - BrowserModule.withServerTransition({ appId: 'serverApp' }), + BrowserModule.withServerTransition({ appId: 'dspace-angular' }), ...IMPORTS ], providers: [ diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index a02095d834..5bd4f745d9 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -49,6 +49,7 @@ import { import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer'; +import { correlationIdReducer } from './correlation-id/correlation-id.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -69,6 +70,7 @@ export interface AppState { communityList: CommunityListState; epeopleRegistry: EPeopleRegistryState; groupRegistry: GroupRegistryState; + correlationId: string; } export const appReducers: ActionReducerMap = { @@ -90,6 +92,7 @@ export const appReducers: ActionReducerMap = { communityList: CommunityListReducer, epeopleRegistry: ePeopleRegistryReducer, groupRegistry: groupRegistryReducer, + correlationId: correlationIdReducer }; export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/bitstream-page/bitstream-page.module.ts b/src/app/bitstream-page/bitstream-page.module.ts index 80e5ad14e3..d168a06db2 100644 --- a/src/app/bitstream-page/bitstream-page.module.ts +++ b/src/app/bitstream-page/bitstream-page.module.ts @@ -4,6 +4,8 @@ import { SharedModule } from '../shared/shared.module'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; +import { FormModule } from '../shared/form/form.module'; +import { ResourcePoliciesModule } from '../shared/resource-policies/resource-policies.module'; /** * This module handles all components that are necessary for Bitstream related pages @@ -12,7 +14,9 @@ import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bit imports: [ CommonModule, SharedModule, - BitstreamPageRoutingModule + BitstreamPageRoutingModule, + FormModule, + ResourcePoliciesModule ], declarations: [ BitstreamAuthorizationsComponent, diff --git a/src/app/breadcrumbs/breadcrumbs.component.html b/src/app/breadcrumbs/breadcrumbs.component.html index beb5039178..51524fde48 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.html +++ b/src/app/breadcrumbs/breadcrumbs.component.html @@ -10,11 +10,11 @@ - + - + diff --git a/src/app/breadcrumbs/breadcrumbs.component.scss b/src/app/breadcrumbs/breadcrumbs.component.scss index 6967b53de1..412dca87db 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.scss +++ b/src/app/breadcrumbs/breadcrumbs.component.scss @@ -10,6 +10,19 @@ background-color: var(--ds-breadcrumb-bg); } +li.breadcrumb-item { + display: flex; +} + +.breadcrumb-item-limiter { + display: inline-block; + max-width: var(--ds-breadcrumb-max-length); + > * { + max-width: 100%; + display: block; + } +} + li.breadcrumb-item > a { color: var(--ds-breadcrumb-link-color) !important; } @@ -18,5 +31,6 @@ li.breadcrumb-item.active { } .breadcrumb-item+ .breadcrumb-item::before { + display: block; content: quote("•") !important; } diff --git a/src/app/breadcrumbs/breadcrumbs.component.spec.ts b/src/app/breadcrumbs/breadcrumbs.component.spec.ts index b63a7cee20..69387e7534 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/app/breadcrumbs/breadcrumbs.component.spec.ts @@ -72,7 +72,7 @@ describe('BreadcrumbsComponent', () => { expect(breadcrumbs.length).toBe(3); expectBreadcrumb(breadcrumbs[0], 'home.breadcrumbs', '/'); expectBreadcrumb(breadcrumbs[1], 'bc 1', '/example.com'); - expectBreadcrumb(breadcrumbs[2], 'bc 2', null); + expectBreadcrumb(breadcrumbs[2].query(By.css('.text-truncate')), 'bc 2', null); }); }); diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts index 3158c3d7cc..1bdbb91a8b 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { environment } from '../../../environments/environment'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; @@ -29,13 +29,13 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * An example would be 'dateissued' for 'dc.date.issued' */ -@rendersBrowseBy(BrowseByType.Date) +@rendersBrowseBy(BrowseByDataType.Date) export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { /** - * The default metadata-field to use for determining the lower limit of the StartsWith dropdown options + * The default metadata keys to use for determining the lower limit of the StartsWith dropdown options */ - defaultMetadataField = 'dc.date.issued'; + defaultMetadataKeys = ['dc.date.issued']; public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, @@ -59,13 +59,13 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { return [Object.assign({}, routeParams, queryParams, data), currentPage, currentSort]; }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - const metadataField = params.metadataField || this.defaultMetadataField; - this.browseId = params.id || this.defaultBrowseId; - this.startsWith = +params.startsWith || params.startsWith; + const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; + this.browseId = params.id || this.defaultBrowseId; + this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId); - this.updatePageWithItems(searchOptions, this.value); + this.updatePageWithItems(searchOptions, this.value, undefined); this.updateParent(params.scope); - this.updateStartsWithOptions(this.browseId, metadataField, params.scope); + this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope); })); } @@ -76,15 +76,15 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { * extremely long lists with a one-year difference. * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. * @param definition The metadata definition to fetch the first item for - * @param metadataField The metadata field to fetch the earliest date from (expects a date field) + * @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field) * @param scope The scope under which to fetch the earliest item for */ - updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { + updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { this.subs.push( this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { let lowerLimit = environment.browseBy.defaultLowerLimit; if (hasValue(firstItemRD.payload)) { - const date = firstItemRD.payload.firstMetadataValue(metadataField); + const date = firstItemRD.payload.firstMetadataValue(metadataKeys); if (hasValue(date)) { const dateObj = new Date(date); // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. @@ -120,5 +120,4 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { }) ); } - } diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index 4592f47175..fc483d87e2 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -1,20 +1,25 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; import { of as observableOf } from 'rxjs'; +import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { BrowseDefinition } from '../core/shared/browse-definition.model'; +import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; describe('BrowseByGuard', () => { describe('canActivate', () => { let guard: BrowseByGuard; let dsoService: any; let translateService: any; + let browseDefinitionService: any; const name = 'An interesting DSO'; const title = 'Author'; const field = 'Author'; const id = 'author'; - const metadataField = 'dc.contributor'; const scope = '1234-65487-12354-1235'; const value = 'Filter'; + const browseDefinition = Object.assign(new BrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { dsoService = { @@ -24,14 +29,19 @@ describe('BrowseByGuard', () => { translateService = { instant: () => field }; - guard = new BrowseByGuard(dsoService, translateService); + + browseDefinitionService = { + findById: () => createSuccessfulRemoteDataObject$(browseDefinition) + }; + + guard = new BrowseByGuard(dsoService, translateService, browseDefinitionService); }); it('should return true, and sets up the data correctly, with a scope and value', () => { const scopedRoute = { data: { title: field, - metadataField, + browseDefinition, }, params: { id, @@ -48,7 +58,7 @@ describe('BrowseByGuard', () => { const result = { title, id, - metadataField, + browseDefinition, collection: name, field, value: '"' + value + '"' @@ -63,7 +73,7 @@ describe('BrowseByGuard', () => { const scopedNoValueRoute = { data: { title: field, - metadataField, + browseDefinition, }, params: { id, @@ -80,7 +90,7 @@ describe('BrowseByGuard', () => { const result = { title, id, - metadataField, + browseDefinition, collection: name, field, value: '' @@ -95,7 +105,7 @@ describe('BrowseByGuard', () => { const route = { data: { title: field, - metadataField, + browseDefinition, }, params: { id, @@ -111,7 +121,7 @@ describe('BrowseByGuard', () => { const result = { title, id, - metadataField, + browseDefinition, collection: '', field, value: '"' + value + '"' diff --git a/src/app/browse-by/browse-by-guard.ts b/src/app/browse-by/browse-by-guard.ts index 8ac77bbd64..e4582cb77a 100644 --- a/src/app/browse-by/browse-by-guard.ts +++ b/src/app/browse-by/browse-by-guard.ts @@ -2,11 +2,12 @@ import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angul import { Injectable } from '@angular/core'; import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; import { hasNoValue, hasValue } from '../shared/empty.util'; -import { map } from 'rxjs/operators'; -import { getFirstSucceededRemoteData } from '../core/shared/operators'; +import { map, switchMap } from 'rxjs/operators'; +import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; import { TranslateService } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; -import { environment } from '../../environments/environment'; +import { Observable, of as observableOf } from 'rxjs'; +import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service'; +import { BrowseDefinition } from '../core/shared/browse-definition.model'; @Injectable() /** @@ -15,42 +16,46 @@ import { environment } from '../../environments/environment'; export class BrowseByGuard implements CanActivate { constructor(protected dsoService: DSpaceObjectDataService, - protected translate: TranslateService) { + protected translate: TranslateService, + protected browseDefinitionService: BrowseDefinitionDataService) { } canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const title = route.data.title; const id = route.params.id || route.queryParams.id || route.data.id; - let metadataField = route.data.metadataField; - if (hasNoValue(metadataField) && hasValue(id)) { - const config = environment.browseBy.types.find((conf) => conf.id === id); - if (hasValue(config) && hasValue(config.metadataField)) { - metadataField = config.metadataField; - } + let browseDefinition$: Observable; + if (hasNoValue(route.data.browseDefinition) && hasValue(id)) { + browseDefinition$ = this.browseDefinitionService.findById(id).pipe(getFirstSucceededRemoteDataPayload()); + } else { + browseDefinition$ = observableOf(route.data.browseDefinition); } const scope = route.queryParams.scope; const value = route.queryParams.value; const metadataTranslated = this.translate.instant('browse.metadata.' + id); - if (hasValue(scope)) { - const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteData()); - return dsoAndMetadata$.pipe( - map((dsoRD) => { - const name = dsoRD.payload.name; - route.data = this.createData(title, id, metadataField, name, metadataTranslated, value, route); - return true; - }) - ); - } else { - route.data = this.createData(title, id, metadataField, '', metadataTranslated, value, route); - return observableOf(true); - } + return browseDefinition$.pipe( + switchMap((browseDefinition) => { + if (hasValue(scope)) { + const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteData()); + return dsoAndMetadata$.pipe( + map((dsoRD) => { + const name = dsoRD.payload.name; + route.data = this.createData(title, id, browseDefinition, name, metadataTranslated, value, route); + return true; + }) + ); + } else { + route.data = this.createData(title, id, browseDefinition, '', metadataTranslated, value, route); + return observableOf(true); + } + }) + ); } - private createData(title, id, metadataField, collection, field, value, route) { + private createData(title, id, browseDefinition, collection, field, value, route) { return Object.assign({}, route.data, { title: title, id: id, - metadataField: metadataField, + browseDefinition: browseDefinition, collection: collection, field: field, value: hasValue(value) ? `"${value}"` : '' diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index 3573ffb264..f789389697 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -14,7 +14,7 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; @@ -28,7 +28,7 @@ import { map } from 'rxjs/operators'; * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * An example would be 'author' for 'dc.contributor.*' */ -@rendersBrowseBy(BrowseByType.Metadata) +@rendersBrowseBy(BrowseByDataType.Metadata) export class BrowseByMetadataPageComponent implements OnInit { /** @@ -99,6 +99,11 @@ export class BrowseByMetadataPageComponent implements OnInit { */ value = ''; + /** + * The authority key (may be undefined) associated with {@link #value}. + */ + authority: string; + /** * The current startsWith option (fetched and updated from query-params) */ @@ -123,11 +128,12 @@ export class BrowseByMetadataPageComponent implements OnInit { }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { this.browseId = params.id || this.defaultBrowseId; + this.authority = params.authority; this.value = +params.value || params.value || ''; this.startsWith = +params.startsWith || params.startsWith; const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId); if (isNotEmpty(this.value)) { - this.updatePageWithItems(searchOptions, this.value); + this.updatePageWithItems(searchOptions, this.value, this.authority); } else { this.updatePage(searchOptions); } @@ -166,8 +172,8 @@ export class BrowseByMetadataPageComponent implements OnInit { * scope: string } * @param value The value of the browse-entry to display items for */ - updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) { - this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions); + updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string, authority: string) { + this.items$ = this.browseService.getBrowseItemsFor(value, authority, searchOptions); } /** diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts index f54efb9378..19a6277151 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts @@ -1,9 +1,9 @@ -import { BrowseByType, rendersBrowseBy } from './browse-by-decorator'; +import { BrowseByDataType, rendersBrowseBy } from './browse-by-decorator'; describe('BrowseByDecorator', () => { - const titleDecorator = rendersBrowseBy(BrowseByType.Title); - const dateDecorator = rendersBrowseBy(BrowseByType.Date); - const metadataDecorator = rendersBrowseBy(BrowseByType.Metadata); + const titleDecorator = rendersBrowseBy(BrowseByDataType.Title); + const dateDecorator = rendersBrowseBy(BrowseByDataType.Date); + const metadataDecorator = rendersBrowseBy(BrowseByDataType.Metadata); it('should have a decorator for all types', () => { expect(titleDecorator.length).not.toEqual(0); expect(dateDecorator.length).not.toEqual(0); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index efb4a4a9f4..1ebaa7face 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -2,13 +2,13 @@ import { hasNoValue } from '../../shared/empty.util'; import { InjectionToken } from '@angular/core'; import { GenericConstructor } from '../../core/shared/generic-constructor'; -export enum BrowseByType { +export enum BrowseByDataType { Title = 'title', - Metadata = 'metadata', + Metadata = 'text', Date = 'date' } -export const DEFAULT_BROWSE_BY_TYPE = BrowseByType.Metadata; +export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata; export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor>('getComponentByBrowseByType', { providedIn: 'root', @@ -21,7 +21,7 @@ const map = new Map(); * Decorator used for rendering Browse-By pages by type * @param browseByType The type of page */ -export function rendersBrowseBy(browseByType: BrowseByType) { +export function rendersBrowseBy(browseByType: BrowseByDataType) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { map.set(browseByType, component); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index f340237e26..cb82ddb7c4 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -2,20 +2,46 @@ import { BrowseBySwitcherComponent } from './browse-by-switcher.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { environment } from '../../../environments/environment'; -import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; +import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { BehaviorSubject, of as observableOf } from 'rxjs'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; let fixture: ComponentFixture; - const types = environment.browseBy.types; + const types = [ + Object.assign( + new BrowseDefinition(), { + id: 'title', + dataType: BrowseByDataType.Title, + } + ), + Object.assign( + new BrowseDefinition(), { + id: 'dateissued', + dataType: BrowseByDataType.Date, + metadataKeys: ['dc.date.issued'] + } + ), + Object.assign( + new BrowseDefinition(), { + id: 'author', + dataType: BrowseByDataType.Metadata, + } + ), + Object.assign( + new BrowseDefinition(), { + id: 'subject', + dataType: BrowseByDataType.Metadata, + } + ), + ]; - const params = new BehaviorSubject(createParamsWithId('initialValue')); + const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition())); const activatedRouteStub = { - params: params + data }; beforeEach(waitForAsync(() => { @@ -34,20 +60,20 @@ describe('BrowseBySwitcherComponent', () => { comp = fixture.componentInstance; })); - types.forEach((type) => { + types.forEach((type: BrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { beforeEach(() => { - params.next(createParamsWithId(type.id)); + data.next(createDataWithBrowseDefinition(type)); fixture.detectChanges(); }); - it(`should call getComponentByBrowseByType with type "${type.type}"`, () => { - expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.type); + it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => { + expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType); }); }); }); }); -export function createParamsWithId(id) { - return { id: id }; +export function createDataWithBrowseDefinition(browseDefinition) { + return { browseDefinition: browseDefinition }; } diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 043a4ce90a..cf4c1d9856 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -1,11 +1,10 @@ import { Component, Inject, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface'; import { map } from 'rxjs/operators'; import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; -import { environment } from '../../../environments/environment'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; @Component({ selector: 'ds-browse-by-switcher', @@ -26,15 +25,11 @@ export class BrowseBySwitcherComponent implements OnInit { } /** - * Fetch the correct browse-by component by using the relevant config from environment.js + * Fetch the correct browse-by component by using the relevant config from the route data */ ngOnInit(): void { - this.browseByComponent = this.route.params.pipe( - map((params) => { - const id = params.id; - return environment.browseBy.types.find((config: BrowseByTypeConfig) => config.id === id); - }), - map((config: BrowseByTypeConfig) => this.getComponentByBrowseByType(config.type)) + this.browseByComponent = this.route.data.pipe( + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType)) ); } diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts index b3a2ceed00..b2798b7fa8 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts @@ -10,7 +10,7 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search- import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { BrowseService } from '../../core/browse/browse.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -23,7 +23,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c /** * Component for browsing items by title (dc.title) */ -@rendersBrowseBy(BrowseByType.Title) +@rendersBrowseBy(BrowseByDataType.Title) export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { public constructor(protected route: ActivatedRoute, @@ -46,7 +46,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { this.browseId = params.id || this.defaultBrowseId; - this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId), undefined); + this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId), undefined, undefined); this.updateParent(params.scope); })); this.updateStartsWithTextOptions(); diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index 2d3618aae6..e1dfaacea5 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -6,6 +6,7 @@ import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse- import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; +import { ComcolModule } from '../shared/comcol/comcol.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -17,6 +18,7 @@ const ENTRY_COMPONENTS = [ @NgModule({ imports: [ CommonModule, + ComcolModule, SharedModule ], declarations: [ @@ -31,7 +33,7 @@ const ENTRY_COMPONENTS = [ export class BrowseByModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index 7835ccc8e5..bb84153835 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -10,7 +10,7 @@ import { } from '@ng-dynamic-forms/core'; import { Collection } from '../../core/shared/collection.model'; -import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; +import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CommunityDataService } from '../../core/data/community-data.service'; import { AuthService } from '../../core/auth/auth.service'; @@ -28,8 +28,8 @@ import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type */ @Component({ selector: 'ds-collection-form', - styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'], - templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html' + styleUrls: ['../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.scss'], + templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html' }) export class CollectionFormComponent extends ComColFormComponent implements OnInit { /** diff --git a/src/app/collection-page/collection-form/collection-form.module.ts b/src/app/collection-page/collection-form/collection-form.module.ts index a9ff5f3ea9..ddf18f0586 100644 --- a/src/app/collection-page/collection-form/collection-form.module.ts +++ b/src/app/collection-page/collection-form/collection-form.module.ts @@ -2,9 +2,13 @@ import { NgModule } from '@angular/core'; import { CollectionFormComponent } from './collection-form.component'; import { SharedModule } from '../../shared/shared.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { FormModule } from '../../shared/form/form.module'; @NgModule({ imports: [ + ComcolModule, + FormModule, SharedModule ], declarations: [ diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 0dfd013449..e8d8d3eb11 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -33,7 +33,7 @@ import { ErrorComponent } from '../../shared/error/error.component'; import { LoadingComponent } from '../../shared/loading/loading.component'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { SearchService } from '../../core/shared/search/search.service'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts index 9a93457436..3172616efc 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -9,10 +9,11 @@ import { Collection } from '../../core/shared/collection.model'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { map, startWith, switchMap, take } from 'rxjs/operators'; import { - getRemoteDataPayload, - getFirstSucceededRemoteData, - toDSpaceObjectListRD, - getFirstCompletedRemoteData, getAllSucceededRemoteData + getAllSucceededRemoteData, + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload, + toDSpaceObjectListRD } from '../../core/shared/operators'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; @@ -24,7 +25,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; import { isNotEmpty } from '../../shared/empty.util'; import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { SearchService } from '../../core/shared/search/search.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { NoContent } from '../../core/shared/NoContent.model'; diff --git a/src/app/collection-page/collection-page.component.ts b/src/app/collection-page/collection-page.component.ts index 366e1da7b1..be602f8458 100644 --- a/src/app/collection-page/collection-page.component.ts +++ b/src/app/collection-page/collection-page.component.ts @@ -1,13 +1,8 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { - BehaviorSubject, - combineLatest as observableCombineLatest, - Observable, - Subject -} from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; import { SearchService } from '../core/shared/search/search.service'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; @@ -103,20 +98,20 @@ export class CollectionPageComponent implements OnInit { const currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, this.sortConfig); this.itemRD$ = observableCombineLatest([currentPagination$, currentSort$]).pipe( - switchMap(([currentPagination, currentSort ]) => this.collectionRD$.pipe( + switchMap(([currentPagination, currentSort]) => this.collectionRD$.pipe( getFirstSucceededRemoteData(), map((rd) => rd.payload.id), switchMap((id: string) => { return this.searchService.search( - new PaginatedSearchOptions({ - scope: id, - pagination: currentPagination, - sort: currentSort, - dsoTypes: [DSpaceObjectType.ITEM] - })).pipe(toDSpaceObjectListRD()) as Observable>>; + new PaginatedSearchOptions({ + scope: id, + pagination: currentPagination, + sort: currentSort, + dsoTypes: [DSpaceObjectType.ITEM] + })).pipe(toDSpaceObjectListRD()) as Observable>>; }), startWith(undefined) // Make sure switching pages shows loading component - ) + ) ) ); diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index a13731ed23..3652823200 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -14,6 +14,7 @@ import { SearchService } from '../core/shared/search/search.service'; import { StatisticsModule } from '../statistics/statistics.module'; import { CollectionFormModule } from './collection-form/collection-form.module'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; +import { ComcolModule } from '../shared/comcol/comcol.module'; @NgModule({ imports: [ @@ -22,7 +23,8 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen CollectionPageRoutingModule, StatisticsModule.forRoot(), EditItemPageModule, - CollectionFormModule + CollectionFormModule, + ComcolModule ], declarations: [ CollectionPageComponent, diff --git a/src/app/collection-page/create-collection-page/create-collection-page.component.ts b/src/app/collection-page/create-collection-page/create-collection-page.component.ts index a38739c407..a938b37ce1 100644 --- a/src/app/collection-page/create-collection-page/create-collection-page.component.ts +++ b/src/app/collection-page/create-collection-page/create-collection-page.component.ts @@ -2,12 +2,12 @@ import { Component } from '@angular/core'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RouteService } from '../../core/services/route.service'; import { Router } from '@angular/router'; -import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; import { Collection } from '../../core/shared/collection.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import {RequestService} from '../../core/data/request.service'; +import { RequestService } from '../../core/data/request.service'; /** * Component that represents the page where a user can create a new Collection diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts index 8daba0abfc..3e30373070 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts @@ -1,11 +1,11 @@ import { Component } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { TranslateService } from '@ngx-translate/core'; -import {RequestService} from '../../core/data/request.service'; +import { RequestService } from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Collection diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index 8cb10775ac..cfaad3767e 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { Collection } from '../../../core/shared/collection.model'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { ActivatedRoute, Router } from '@angular/router'; diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index 401065a661..985290a592 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -12,6 +12,7 @@ import { RequestService } from '../../../core/data/request.service'; import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ComcolModule } from '../../../shared/comcol/comcol.module'; describe('CollectionRolesComponent', () => { @@ -65,6 +66,7 @@ describe('CollectionRolesComponent', () => { TestBed.configureTestingModule({ imports: [ + ComcolModule, SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts index aff1995a14..62fbb3ee3d 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { Collection } from '../../core/shared/collection.model'; import { getCollectionPageRoute } from '../collection-page-routing-paths'; @@ -9,7 +9,7 @@ import { getCollectionPageRoute } from '../collection-page-routing-paths'; */ @Component({ selector: 'ds-edit-collection', - templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCollectionPageComponent extends EditComColPageComponent { type = 'collection'; diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts index 0b09542fa0..45612be41a 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts @@ -10,6 +10,9 @@ import { CollectionSourceComponent } from './collection-source/collection-source import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; import { CollectionFormModule } from '../collection-form/collection-form.module'; import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component'; +import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; +import { FormModule } from '../../shared/form/form.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; /** * Module that contains all components related to the Edit Collection page administrator functionality @@ -19,7 +22,10 @@ import { CollectionSourceControlsComponent } from './collection-source/collectio CommonModule, SharedModule, EditCollectionPageRoutingModule, - CollectionFormModule + CollectionFormModule, + ResourcePoliciesModule, + FormModule, + ComcolModule ], declarations: [ EditCollectionPageComponent, diff --git a/src/app/community-page/community-form/community-form.component.ts b/src/app/community-page/community-form/community-form.component.ts index 59480e8f68..a3730fd418 100644 --- a/src/app/community-page/community-form/community-form.component.ts +++ b/src/app/community-page/community-form/community-form.component.ts @@ -6,7 +6,7 @@ import { DynamicTextAreaModel } from '@ng-dynamic-forms/core'; import { Community } from '../../core/shared/community.model'; -import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; +import { ComColFormComponent } from '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component'; import { TranslateService } from '@ngx-translate/core'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CommunityDataService } from '../../core/data/community-data.service'; @@ -19,8 +19,8 @@ import { ObjectCacheService } from '../../core/cache/object-cache.service'; */ @Component({ selector: 'ds-community-form', - styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'], - templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html' + styleUrls: ['../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.scss'], + templateUrl: '../../shared/comcol/comcol-forms/comcol-form/comcol-form.component.html' }) export class CommunityFormComponent extends ComColFormComponent { /** diff --git a/src/app/community-page/community-form/community-form.module.ts b/src/app/community-page/community-form/community-form.module.ts index 36dbea5c0d..925d218973 100644 --- a/src/app/community-page/community-form/community-form.module.ts +++ b/src/app/community-page/community-form/community-form.module.ts @@ -2,9 +2,13 @@ import { NgModule } from '@angular/core'; import { CommunityFormComponent } from './community-form.component'; import { SharedModule } from '../../shared/shared.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { FormModule } from '../../shared/form/form.module'; @NgModule({ imports: [ + ComcolModule, + FormModule, SharedModule ], declarations: [ diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts index 3ae75f166c..724b762e90 100644 --- a/src/app/community-page/community-page.module.ts +++ b/src/app/community-page/community-page.module.ts @@ -12,6 +12,7 @@ import { DeleteCommunityPageComponent } from './delete-community-page/delete-com import { StatisticsModule } from '../statistics/statistics.module'; import { CommunityFormModule } from './community-form/community-form.module'; import { ThemedCommunityPageComponent } from './themed-community-page.component'; +import { ComcolModule } from '../shared/comcol/comcol.module'; const DECLARATIONS = [CommunityPageComponent, ThemedCommunityPageComponent, @@ -26,7 +27,8 @@ const DECLARATIONS = [CommunityPageComponent, SharedModule, CommunityPageRoutingModule, StatisticsModule.forRoot(), - CommunityFormModule + CommunityFormModule, + ComcolModule ], declarations: [ ...DECLARATIONS diff --git a/src/app/community-page/create-community-page/create-community-page.component.ts b/src/app/community-page/create-community-page/create-community-page.component.ts index be3385b92f..b332fad100 100644 --- a/src/app/community-page/create-community-page/create-community-page.component.ts +++ b/src/app/community-page/create-community-page/create-community-page.component.ts @@ -3,7 +3,7 @@ import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { RouteService } from '../../core/services/route.service'; import { Router } from '@angular/router'; -import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { CreateComColPageComponent } from '../../shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../core/data/request.service'; diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.ts b/src/app/community-page/delete-community-page/delete-community-page.component.ts index ec51076bbc..0cccc503e1 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.ts @@ -2,10 +2,10 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component'; +import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import {RequestService} from '../../core/data/request.service'; +import { RequestService } from '../../core/data/request.service'; /** * Component that represents the page where a user can delete an existing Community diff --git a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts index 8b241af667..7a9f224311 100644 --- a/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts +++ b/src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts @@ -2,8 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; import { first, map } from 'rxjs/operators'; -import { RemoteData } from 'src/app/core/data/remote-data'; -import { DSpaceObject } from 'src/app/core/shared/dspace-object.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; @Component({ selector: 'ds-community-authorizations', diff --git a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts index c4bb88289f..a2dbfa6eb6 100644 --- a/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts +++ b/src/app/community-page/edit-community-page/community-metadata/community-metadata.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { ComcolMetadataComponent } from '../../../shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { ComcolMetadataComponent } from '../../../shared/comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { ActivatedRoute, Router } from '@angular/router'; import { Community } from '../../../core/shared/community.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts index b61705126c..d1188df02d 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts @@ -12,6 +12,7 @@ import { SharedModule } from '../../../shared/shared.module'; import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ComcolModule } from '../../../shared/comcol/comcol.module'; describe('CommunityRolesComponent', () => { @@ -50,6 +51,7 @@ describe('CommunityRolesComponent', () => { TestBed.configureTestingModule({ imports: [ + ComcolModule, SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), diff --git a/src/app/community-page/edit-community-page/edit-community-page.component.ts b/src/app/community-page/edit-community-page/edit-community-page.component.ts index 836384ab84..54a6ee4944 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { ActivatedRoute, Router } from '@angular/router'; -import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { EditComColPageComponent } from '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { getCommunityPageRoute } from '../community-page-routing-paths'; /** @@ -9,7 +9,7 @@ import { getCommunityPageRoute } from '../community-page-routing-paths'; */ @Component({ selector: 'ds-edit-community', - templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' + templateUrl: '../../shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCommunityPageComponent extends EditComColPageComponent { type = 'community'; diff --git a/src/app/community-page/edit-community-page/edit-community-page.module.ts b/src/app/community-page/edit-community-page/edit-community-page.module.ts index 3f7511f700..2b0fc73f2a 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.module.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.module.ts @@ -8,6 +8,8 @@ import { CommunityMetadataComponent } from './community-metadata/community-metad import { CommunityRolesComponent } from './community-roles/community-roles.component'; import { CommunityAuthorizationsComponent } from './community-authorizations/community-authorizations.component'; import { CommunityFormModule } from '../community-form/community-form.module'; +import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; +import { ComcolModule } from '../../shared/comcol/comcol.module'; /** * Module that contains all components related to the Edit Community page administrator functionality @@ -17,7 +19,9 @@ import { CommunityFormModule } from '../community-form/community-form.module'; CommonModule, SharedModule, EditCommunityPageRoutingModule, - CommunityFormModule + CommunityFormModule, + ComcolModule, + ResourcePoliciesModule ], declarations: [ EditCommunityPageComponent, diff --git a/src/app/core/browse/browse-definition-data.service.spec.ts b/src/app/core/browse/browse-definition-data.service.spec.ts index 1127748ca9..d6770f80c0 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -9,9 +9,11 @@ describe(`BrowseDefinitionDataService`, () => { findAll: EMPTY, findByHref: EMPTY, findAllByHref: EMPTY, + findById: EMPTY, }); const hrefAll = 'https://rest.api/server/api/discover/browses'; const hrefSingle = 'https://rest.api/server/api/discover/browses/author'; + const id = 'author'; const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), @@ -44,4 +46,10 @@ describe(`BrowseDefinitionDataService`, () => { }); }); + describe(`findById`, () => { + it(`should call findById on DataServiceImpl`, () => { + service.findAllByHref(id, options, true, false, ...linksToFollow); + expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow); + }); + }); }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 31338417ca..dd66d8fa53 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -106,6 +106,21 @@ export class BrowseDefinitionDataService { findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index a28add2e30..ac68fadb31 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -129,6 +129,7 @@ describe('BrowseService', () => { describe('getBrowseEntriesFor and findList', () => { // should contain special characters such that url encoding can be tested as well const mockAuthorName = 'Donald Smith & Sons'; + const mockAuthorityKey = 'some authority key ?=;'; beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); @@ -155,7 +156,7 @@ describe('BrowseService', () => { it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName); - scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); + scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, undefined, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { @@ -164,6 +165,20 @@ describe('BrowseService', () => { }); }); + describe('when getBrowseItemsFor is called with a valid filter value and authority key', () => { + it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + const expected = browseDefinitions[1]._links.items.href + + '?filterValue=' + encodeURIComponent(mockAuthorName) + + '&filterAuthority=' + encodeURIComponent(mockAuthorityKey); + + scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, mockAuthorityKey, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); + scheduler.flush(); + + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + a: expected + })); + }); + }); }); describe('getBrowseURLFor', () => { diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index ffc6f313b9..05e625d6c1 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -105,7 +105,7 @@ export class BrowseService { * @param options Options to narrow down your search * @returns {Observable>>} */ - getBrowseItemsFor(filterValue: string, options: BrowseEntrySearchOptions): Observable>> { + getBrowseItemsFor(filterValue: string, filterAuthority: string, options: BrowseEntrySearchOptions): Observable>> { const href$ = this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), @@ -132,6 +132,9 @@ export class BrowseService { if (isNotEmpty(filterValue)) { args.push(`filterValue=${encodeURIComponent(filterValue)}`); } + if (isNotEmpty(filterAuthority)) { + args.push(`filterAuthority=${encodeURIComponent(filterAuthority)}`); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index da7e0adc47..5baddea719 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -164,6 +164,7 @@ import { RootDataService } from './data/root-data.service'; import { Root } from './data/root.model'; import { SearchConfig } from './shared/search/search-filters/search-config.model'; import { SequenceService } from './shared/sequence.service'; +import { GroupDataService } from './eperson/group-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -286,6 +287,7 @@ const PROVIDERS = [ VocabularyService, VocabularyTreeviewService, SequenceService, + GroupDataService FeedbackDataService, // FeedbackGuard ]; diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index bff21d2c8d..3c885c0afd 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -20,7 +20,7 @@ import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { FindListOptions, GetRequest } from './request.models'; import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request.reducer'; diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index a3a0a532ec..d2fc9e6d96 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -12,7 +12,7 @@ import { HttpClient } from '@angular/common/http'; import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index fc543c9072..8c24bd61d9 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@angular/core'; -import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; +import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model'; import { ParsedResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RestRequest } from './request.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; -import { FacetConfigResponse } from '../../shared/search/facet-config-response.model'; +import { FacetConfigResponse } from '../../shared/search/models/facet-config-response.model'; @Injectable() export class FacetConfigResponseParsingService extends DspaceRestResponseParsingService { diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 6b9e832685..12a2d4ba8c 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; -import { FacetValue } from '../../shared/search/facet-value.model'; +import { FacetValue } from '../../shared/search/models/facet-value.model'; import { ParsedResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RestRequest } from './request.models'; -import { FacetValues } from '../../shared/search/facet-values.model'; +import { FacetValues } from '../../shared/search/models/facet-values.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; @Injectable() diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts new file mode 100644 index 0000000000..680495686e --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/statistics-administrator.guard.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { AuthService } from '../../../auth/auth.service'; +import { Observable, of as observableOf } from 'rxjs'; +import { FeatureID } from '../feature-id'; + +/** + * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group + * management rights + */ +@Injectable({ + providedIn: 'root' +}) +export class StatisticsAdministratorGuard extends SingleFeatureAuthorizationGuard { + constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) { + super(authorizationService, router, authService); + } + + /** + * Check group management rights + */ + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.CanViewUsageStatistics); + } +} diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 7df2a30b9f..029c75d9cb 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -13,6 +13,7 @@ export enum FeatureID { CanManageGroup = 'canManageGroup', IsCollectionAdmin = 'isCollectionAdmin', IsCommunityAdmin = 'isCommunityAdmin', + CanChangePassword = 'canChangePassword', CanDownload = 'canDownload', CanRequestACopy = 'canRequestACopy', CanManageVersions = 'canManageVersions', @@ -25,5 +26,6 @@ export enum FeatureID { CanEditVersion = 'canEditVersion', CanDeleteVersion = 'canDeleteVersion', CanCreateVersion = 'canCreateVersion', + CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index c31b6b3c97..a8d380124e 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -25,7 +25,7 @@ import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models'; import { RequestService } from './request.service'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bundle } from '../shared/bundle.model'; import { MetadataMap } from '../shared/metadata.models'; import { BundleDataService } from './bundle-data.service'; diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 876336bfa9..c9e523f796 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -5,9 +5,9 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { createPaginatedList } from '../../shared/testing/utils.test'; import { buildPaginatedList } from './paginated-list.model'; import { PageInfo } from '../shared/page-info.model'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; -import { SearchResult } from '../../shared/search/search-result.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; import { Item } from '../shared/item.model'; import { skip, take } from 'rxjs/operators'; import { ExternalSource } from '../shared/external-source.model'; diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7ecf3a19cc..7808a24e92 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,11 +1,11 @@ import { ExternalSourceService } from './external-source.service'; import { SearchService } from '../shared/search/search.service'; import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Observable, ReplaySubject } from 'rxjs'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; -import { SearchResult } from '../../shared/search/search-result.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { RelationshipOptions } from '../../shared/form/builder/models/relationship-options.model'; import { Item } from '../shared/item.model'; diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index f71eaeb811..e111aca9dd 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -4,7 +4,7 @@ import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { RestRequest } from './request.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { hasValue } from '../../shared/empty.util'; -import { SearchObjects } from '../../shared/search/search-objects.model'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; import { MetadataMap, MetadataValue } from '../shared/metadata.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 667e4a0434..a29c99d326 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -20,7 +20,7 @@ export enum IdentifierType { } export abstract class RestRequest { - public responseMsToLive = environment.cache.msToLive.default; + public responseMsToLive; public isMultipart = false; constructor( @@ -30,6 +30,7 @@ export abstract class RestRequest { public body?: any, public options?: HttpOptions, ) { + this.responseMsToLive = environment.cache.msToLive.default; } getResponseParser(): GenericConstructor { diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index be2fbe90fc..814a2f8d1f 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { hasValue } from '../../shared/empty.util'; -import { SearchObjects } from '../../shared/search/search-objects.model'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; import { ParsedResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index 4268516e6b..9aa4f055ff 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { FindListOptions, PostRequest, RestRequest } from './request.models'; import { Observable, of } from 'rxjs'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { Version } from '../shared/version.model'; diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 5b8f474d1a..9b20650725 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -40,9 +40,7 @@ const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegis /** * Provides methods to retrieve eperson group resources from the REST API & Group related CRUD actions. */ -@Injectable({ - providedIn: 'root' -}) +@Injectable() @dataService(GROUP) export class GroupDataService extends DataService { protected linkPath = 'groups'; diff --git a/src/app/core/log/log.interceptor.spec.ts b/src/app/core/log/log.interceptor.spec.ts index 9bda4b7934..cae9c32202 100644 --- a/src/app/core/log/log.interceptor.spec.ts +++ b/src/app/core/log/log.interceptor.spec.ts @@ -9,12 +9,17 @@ import { RestRequestMethod } from '../data/rest-request-method'; import { CookieService } from '../services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { RouterStub } from '../../shared/testing/router.stub'; +import { CorrelationIdService } from '../../correlation-id/correlation-id.service'; +import { UUIDService } from '../shared/uuid.service'; +import { StoreModule } from '@ngrx/store'; +import { appReducers, storeModuleConfig } from '../../app.reducer'; describe('LogInterceptor', () => { let service: DspaceRestService; let httpMock: HttpTestingController; let cookieService: CookieService; + let correlationIdService: CorrelationIdService; const router = Object.assign(new RouterStub(),{url : '/statistics'}); // Mock payload/statuses are dummy content as we are not testing the results @@ -28,7 +33,10 @@ describe('LogInterceptor', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], + imports: [ + HttpClientTestingModule, + StoreModule.forRoot(appReducers, storeModuleConfig), + ], providers: [ DspaceRestService, // LogInterceptor, @@ -39,14 +47,18 @@ describe('LogInterceptor', () => { }, { provide: CookieService, useValue: new CookieServiceMock() }, { provide: Router, useValue: router }, + { provide: CorrelationIdService, useClass: CorrelationIdService }, + { provide: UUIDService, useClass: UUIDService }, ], }); - service = TestBed.get(DspaceRestService); - httpMock = TestBed.get(HttpTestingController); - cookieService = TestBed.get(CookieService); + service = TestBed.inject(DspaceRestService); + httpMock = TestBed.inject(HttpTestingController); + cookieService = TestBed.inject(CookieService); + correlationIdService = TestBed.inject(CorrelationIdService); cookieService.set('CORRELATION-ID','123455'); + correlationIdService.initCorrelationId(); }); diff --git a/src/app/core/log/log.interceptor.ts b/src/app/core/log/log.interceptor.ts index bf843f1da8..aff1a24963 100644 --- a/src/app/core/log/log.interceptor.ts +++ b/src/app/core/log/log.interceptor.ts @@ -3,9 +3,8 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/c import { Router } from '@angular/router'; import { Observable } from 'rxjs'; - -import { CookieService } from '../services/cookie.service'; import { hasValue } from '../../shared/empty.util'; +import { CorrelationIdService } from '../../correlation-id/correlation-id.service'; /** * Log Interceptor intercepting Http Requests & Responses to @@ -15,12 +14,12 @@ import { hasValue } from '../../shared/empty.util'; @Injectable() export class LogInterceptor implements HttpInterceptor { - constructor(private cookieService: CookieService, private router: Router) {} + constructor(private cidService: CorrelationIdService, private router: Router) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { - // Get Unique id of the user from the cookies - const correlationId = this.cookieService.get('CORRELATION-ID'); + // Get the correlation id for the user from the store + const correlationId = this.cidService.getCorrelationId(); // Add headers from the intercepted request let headers = request.headers; diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 2c08417b6d..68406f3f7d 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -6,6 +6,7 @@ import { BROWSE_DEFINITION } from './browse-definition.resource-type'; import { HALLink } from './hal-link.model'; import { ResourceType } from './resource-type'; import { SortOption } from './sort-option.model'; +import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; @typedObject export class BrowseDefinition extends CacheableObject { @@ -33,6 +34,9 @@ export class BrowseDefinition extends CacheableObject { @autoserializeAs('metadata') metadataKeys: string[]; + @autoserialize + dataType: BrowseByDataType; + get self(): string { return this._links.self.href; } diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 8b71954cbe..b29b8f662e 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -89,7 +89,7 @@ describe('HALEndpointService', () => { describe('getRootEndpointMap', () => { it('should send a new EndpointMapRequest', () => { (service as any).getRootEndpointMap(); - const expected = new EndpointMapRequest(requestService.generateRequestId(), environment.rest.baseUrl + 'api'); + const expected = new EndpointMapRequest(requestService.generateRequestId(), `${environment.rest.baseUrl}/api`); expect(requestService.send).toHaveBeenCalledWith(expected, true); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 3be04447ab..ea2a0283eb 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -13,7 +13,7 @@ import { withLatestFrom } from 'rxjs/operators'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; -import { SearchResult } from '../../shared/search/search-result.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RestRequest } from '../data/request.models'; diff --git a/src/app/core/shared/search/search-configuration.service.spec.ts b/src/app/core/shared/search/search-configuration.service.spec.ts index 805ecd0486..96f9ac5018 100644 --- a/src/app/core/shared/search/search-configuration.service.spec.ts +++ b/src/app/core/shared/search/search-configuration.service.spec.ts @@ -2,8 +2,8 @@ import { SearchConfigurationService } from './search-configuration.service'; import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; -import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; -import { SearchFilter } from '../../../shared/search/search-filter.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { SearchFilter } from '../../../shared/search/models/search-filter.model'; import { of as observableOf } from 'rxjs'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index 8c37fbc8f5..81df63f6b3 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -3,30 +3,25 @@ import { ActivatedRoute, Params } from '@angular/router'; import { BehaviorSubject, - combineLatest, combineLatest as observableCombineLatest, merge as observableMerge, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, map, startWith, switchMap, take } from 'rxjs/operators'; +import { filter, map, startWith } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SearchOptions } from '../../../shared/search/search-options.model'; -import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; -import { SearchFilter } from '../../../shared/search/search-filter.model'; +import { SearchOptions } from '../../../shared/search/models/search-options.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { SearchFilter } from '../../../shared/search/models/search-filter.model'; import { RemoteData } from '../../data/remote-data'; import { DSpaceObjectType } from '../dspace-object-type.model'; import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; import { RouteService } from '../../services/route.service'; -import { - getAllSucceededRemoteDataPayload, - getFirstSucceededRemoteData -} from '../operators'; +import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteData } from '../operators'; import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { SearchConfig } from './search-filters/search-config.model'; +import { SearchConfig, SortConfig } from './search-filters/search-config.model'; import { SearchService } from './search.service'; -import { of } from 'rxjs'; import { PaginationService } from '../../pagination/pagination.service'; /** @@ -35,7 +30,21 @@ import { PaginationService } from '../../pagination/pagination.service'; @Injectable() export class SearchConfigurationService implements OnDestroy { + /** + * Default pagination id + */ public paginationID = 'spc'; + + /** + * Emits the current search options + */ + public searchOptions: BehaviorSubject; + + /** + * Emits the current search options including pagination and sort + */ + public paginatedSearchOptions: BehaviorSubject; + /** * Default pagination settings */ @@ -45,16 +54,6 @@ export class SearchConfigurationService implements OnDestroy { currentPage: 1 }); - /** - * Default sort settings - */ - protected defaultSort = new SortOptions('score', SortDirection.DESC); - - /** - * Default configuration parameter setting - */ - protected defaultConfiguration; - /** * Default scope setting */ @@ -71,23 +70,14 @@ export class SearchConfigurationService implements OnDestroy { protected _defaults: Observable>; /** - * Emits the current search options + * A map of subscriptions to unsubscribe from on destroy */ - public searchOptions: BehaviorSubject; - - /** - * Emits the current search options including pagination and sort - */ - public paginatedSearchOptions: BehaviorSubject; - - /** - * List of subscriptions to unsubscribe from on destroy - */ - protected subs: Subscription[] = []; + protected subs: Map = new Map(null); /** * Initialize the search options * @param {RouteService} routeService + * @param {PaginationService} paginationService * @param {ActivatedRoute} route */ constructor(protected routeService: RouteService, @@ -98,29 +88,28 @@ export class SearchConfigurationService implements OnDestroy { } /** - * Initialize the search options + * Default values for the Search Options */ - protected initDefaults() { - this.defaults - .pipe(getFirstSucceededRemoteData()) - .subscribe((defRD: RemoteData) => { - const defs = defRD.payload; - this.paginatedSearchOptions = new BehaviorSubject(defs); - this.searchOptions = new BehaviorSubject(defs); - this.subs.push(this.subscribeToSearchOptions(defs)); - this.subs.push(this.subscribeToPaginatedSearchOptions(defs.pagination.id, defs)); - } - ); + get defaults(): Observable> { + if (hasNoValue(this._defaults)) { + const options = new PaginatedSearchOptions({ + pagination: this.defaultPagination, + scope: this.defaultScope, + query: this.defaultQuery + }); + this._defaults = createSuccessfulRemoteDataObject$(options, new Date().getTime()); + } + return this._defaults; } /** * @returns {Observable} Emits the current configuration string */ getCurrentConfiguration(defaultConfiguration: string) { - return observableCombineLatest( + return observableCombineLatest([ this.routeService.getQueryParameterValue('configuration').pipe(startWith(undefined)), this.routeService.getRouteParameterValue('configuration').pipe(startWith(undefined)) - ).pipe( + ]).pipe( map(([queryConfig, routeConfig]) => { return queryConfig || routeConfig || defaultConfiguration; }) @@ -208,59 +197,82 @@ export class SearchConfigurationService implements OnDestroy { } /** - * Creates an observable of SearchConfig every time the configuration$ stream emits. - * @param configuration$ - * @param service + * Creates an observable of SearchConfig every time the configuration stream emits. + * @param configuration The search configuration + * @param service The search service to use + * @param scope The search scope if exists */ - getConfigurationSearchConfigObservable(configuration$: Observable, service: SearchService): Observable { - return configuration$.pipe( - distinctUntilChanged(), - switchMap((configuration) => service.getSearchConfigurationFor(null, configuration)), - getAllSucceededRemoteDataPayload()); + getConfigurationSearchConfig(configuration: string, service: SearchService, scope?: string): Observable { + return service.getSearchConfigurationFor(scope, configuration).pipe( + getAllSucceededRemoteDataPayload() + ); } /** - * Every time searchConfig change (after a configuration change) it update the navigation with the default sort option - * and emit the new paginateSearchOptions value. - * @param configuration$ - * @param service + * Return the SortOptions list available for the given SearchConfig + * @param searchConfig The SearchConfig object */ - initializeSortOptionsFromConfiguration(searchConfig$: Observable) { - const subscription = searchConfig$.pipe(switchMap((searchConfig) => combineLatest([ - of(searchConfig), - this.paginatedSearchOptions.pipe(take(1)) - ]))).subscribe(([searchConfig, searchOptions]) => { - const field = searchConfig.sortOptions[0].name; - const direction = searchConfig.sortOptions[0].sortOrder.toLowerCase() === SortDirection.ASC.toLowerCase() ? SortDirection.ASC : SortDirection.DESC; - const updateValue = Object.assign(new PaginatedSearchOptions({}), searchOptions, { - sort: new SortOptions(field, direction) - }); - this.paginationService.updateRoute(this.paginationID, - { - sortDirection: updateValue.sort.direction, - sortField: updateValue.sort.field, - }); - this.paginatedSearchOptions.next(updateValue); - }); - this.subs.push(subscription); - } - - /** - * Creates an observable of available SortOptions[] every time the searchConfig$ stream emits. - * @param searchConfig$ - * @param service - */ - getConfigurationSortOptionsObservable(searchConfig$: Observable): Observable { - return searchConfig$.pipe(map((searchConfig) => { - const sortOptions = []; - searchConfig.sortOptions.forEach(sortOption => { - sortOptions.push(new SortOptions(sortOption.name, SortDirection.ASC)); - sortOptions.push(new SortOptions(sortOption.name, SortDirection.DESC)); - }); - return sortOptions; + getConfigurationSortOptions(searchConfig: SearchConfig): SortOptions[] { + return searchConfig.sortOptions.map((entry: SortConfig) => ({ + field: entry.name, + direction: entry.sortOrder.toLowerCase() === SortDirection.ASC.toLowerCase() ? SortDirection.ASC : SortDirection.DESC })); } + setPaginationId(paginationId): void { + if (isNotEmpty(paginationId)) { + const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); + const updatedValue: PaginatedSearchOptions = Object.assign(new PaginatedSearchOptions({}), currentValue, { + pagination: Object.assign({}, currentValue.pagination, { + id: paginationId + }) + }); + // unsubscribe from subscription related to old pagination id + this.unsubscribeFromSearchOptions(this.paginationID); + + // change to the new pagination id + this.paginationID = paginationId; + this.paginatedSearchOptions.next(updatedValue); + this.setSearchSubscription(this.paginationID, this.paginatedSearchOptions.value); + } + } + + /** + * Make sure to unsubscribe from all existing subscription to prevent memory leaks + */ + ngOnDestroy(): void { + this.subs + .forEach((subs: Subscription[]) => subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()) + ); + + this.subs = new Map(null); + } + + /** + * Initialize the search options + */ + protected initDefaults() { + this.defaults + .pipe(getFirstSucceededRemoteData()) + .subscribe((defRD: RemoteData) => { + const defs = defRD.payload; + this.paginatedSearchOptions = new BehaviorSubject(defs); + this.searchOptions = new BehaviorSubject(defs); + this.setSearchSubscription(this.paginationID, defs); + }); + } + + private setSearchSubscription(paginationID: string, defaults: PaginatedSearchOptions) { + this.unsubscribeFromSearchOptions(paginationID); + const subs = [ + this.subscribeToSearchOptions(defaults), + this.subscribeToPaginatedSearchOptions(paginationID || defaults.pagination.id, defaults) + ]; + this.subs.set(this.paginationID, subs); + } + /** * Sets up a subscription to all necessary parameters to make sure the searchOptions emits a new value every time they update * @param {SearchOptions} defaults Default values for when no parameters are available @@ -283,14 +295,15 @@ export class SearchConfigurationService implements OnDestroy { /** * Sets up a subscription to all necessary parameters to make sure the paginatedSearchOptions emits a new value every time they update + * @param {string} paginationId The pagination ID * @param {PaginatedSearchOptions} defaults Default values for when no parameters are available * @returns {Subscription} The subscription to unsubscribe from */ private subscribeToPaginatedSearchOptions(paginationId: string, defaults: PaginatedSearchOptions): Subscription { return observableMerge( + this.getConfigurationPart(defaults.configuration), this.getPaginationPart(paginationId, defaults.pagination), this.getSortPart(paginationId, defaults.sort), - this.getConfigurationPart(defaults.configuration), this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), @@ -304,30 +317,16 @@ export class SearchConfigurationService implements OnDestroy { } /** - * Default values for the Search Options + * Unsubscribe from all subscriptions related to the given paginationID + * @param paginationId The pagination id */ - get defaults(): Observable> { - if (hasNoValue(this._defaults)) { - const options = new PaginatedSearchOptions({ - pagination: this.defaultPagination, - configuration: this.defaultConfiguration, - sort: this.defaultSort, - scope: this.defaultScope, - query: this.defaultQuery - }); - this._defaults = createSuccessfulRemoteDataObject$(options, new Date().getTime()); + private unsubscribeFromSearchOptions(paginationId: string): void { + if (this.subs.has(this.paginationID)) { + this.subs.get(this.paginationID) + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.subs.delete(paginationId); } - return this._defaults; - } - - /** - * Make sure to unsubscribe from all existing subscription to prevent memory leaks - */ - ngOnDestroy(): void { - this.subs.forEach((sub) => { - sub.unsubscribe(); - }); - this.subs = []; } /** diff --git a/src/app/core/shared/search/search-filter.service.spec.ts b/src/app/core/shared/search/search-filter.service.spec.ts index 045b2b17c9..a42bf8e5f6 100644 --- a/src/app/core/shared/search/search-filter.service.spec.ts +++ b/src/app/core/shared/search/search-filter.service.spec.ts @@ -10,8 +10,8 @@ import { SearchFilterToggleAction } from '../../../shared/search/search-filters/search-filter/search-filter.actions'; import { SearchFiltersState } from '../../../shared/search/search-filters/search-filter/search-filter.reducer'; -import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; -import { FilterType } from '../../../shared/search/filter-type.model'; +import { SearchFilterConfig } from '../../../shared/search/models/search-filter-config.model'; +import { FilterType } from '../../../shared/search/models/filter-type.model'; import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { of as observableOf } from 'rxjs'; import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; diff --git a/src/app/core/shared/search/search-filter.service.ts b/src/app/core/shared/search/search-filter.service.ts index 84d7268abb..00125e31f5 100644 --- a/src/app/core/shared/search/search-filter.service.ts +++ b/src/app/core/shared/search/search-filter.service.ts @@ -16,7 +16,7 @@ import { SearchFilterToggleAction } from '../../../shared/search/search-filters/search-filter/search-filter.actions'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; -import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { SearchFilterConfig } from '../../../shared/search/models/search-filter-config.model'; import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; import { RouteService } from '../../services/route.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; diff --git a/src/app/core/shared/search/search-filters/search-config.model.ts b/src/app/core/shared/search/search-filters/search-config.model.ts index 725761fe7b..e789de0f80 100644 --- a/src/app/core/shared/search/search-filters/search-config.model.ts +++ b/src/app/core/shared/search/search-filters/search-config.model.ts @@ -29,7 +29,7 @@ export class SearchConfig implements CacheableObject { * The configured sort options. */ @autoserialize - sortOptions: SortOption[]; + sortOptions: SortConfig[]; /** * The object type. @@ -63,7 +63,7 @@ export interface FilterConfig { /** * Interface to model sort option's configuration. */ -export interface SortOption { +export interface SortConfig { name: string; sortOrder: string; } diff --git a/src/app/core/shared/search/search.service.spec.ts b/src/app/core/shared/search/search.service.spec.ts index 00f10230c3..f9b768655e 100644 --- a/src/app/core/shared/search/search.service.spec.ts +++ b/src/app/core/shared/search/search.service.spec.ts @@ -9,7 +9,7 @@ import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { RouterStub } from '../../../shared/testing/router.stub'; import { HALEndpointService } from '../hal-endpoint.service'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; import { RemoteData } from '../../data/remote-data'; import { RequestEntry } from '../../data/request.reducer'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; @@ -21,11 +21,8 @@ import { RouteService } from '../../services/route.service'; import { routeServiceStub } from '../../../shared/testing/route-service.stub'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; -import { SearchObjects } from '../../../shared/search/search-objects.model'; +import { SearchObjects } from '../../../shared/search/models/search-objects.model'; import { PaginationService } from '../../pagination/pagination.service'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; -import { FindListOptions } from '../../data/request.models'; import { SearchConfigurationService } from './search-configuration.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 91916a35ac..f70416594d 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -14,24 +14,24 @@ import { GenericConstructor } from '../generic-constructor'; import { HALEndpointService } from '../hal-endpoint.service'; import { URLCombiner } from '../../url-combiner/url-combiner'; import { hasValue, hasValueOperator, isNotEmpty } from '../../../shared/empty.util'; -import { SearchOptions } from '../../../shared/search/search-options.model'; -import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model'; +import { SearchOptions } from '../../../shared/search/models/search-options.model'; +import { SearchFilterConfig } from '../../../shared/search/models/search-filter-config.model'; import { SearchResponseParsingService } from '../../data/search-response-parsing.service'; -import { SearchObjects } from '../../../shared/search/search-objects.model'; +import { SearchObjects } from '../../../shared/search/models/search-objects.model'; import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service'; import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service'; -import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; import { CommunityDataService } from '../../data/community-data.service'; import { ViewMode } from '../view-mode.model'; import { DSpaceObjectDataService } from '../../data/dspace-object-data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators'; import { RouteService } from '../../services/route.service'; -import { SearchResult } from '../../../shared/search/search-result.model'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { getSearchResultFor } from '../../../shared/search/search-result-element-decorator'; -import { FacetConfigResponse } from '../../../shared/search/facet-config-response.model'; -import { FacetValues } from '../../../shared/search/facet-values.model'; +import { FacetConfigResponse } from '../../../shared/search/models/facet-config-response.model'; +import { FacetValues } from '../../../shared/search/models/facet-values.model'; import { SearchConfig } from './search-filters/search-config.model'; import { PaginationService } from '../../pagination/pagination.service'; import { SearchConfigurationService } from './search-configuration.service'; @@ -407,6 +407,7 @@ export class SearchService implements OnDestroy { /** * Changes the current view mode in the current URL * @param {ViewMode} viewMode Mode to switch to + * @param {string[]} searchLinkParts */ setViewMode(viewMode: ViewMode, searchLinkParts?: string[]) { this.paginationService.getCurrentPagination(this.searchConfigurationService.paginationID, new PaginationComponentOptions()).pipe(take(1)) diff --git a/src/app/core/shared/version.model.ts b/src/app/core/shared/version.model.ts index 48d6eb0b68..7207637a21 100644 --- a/src/app/core/shared/version.model.ts +++ b/src/app/core/shared/version.model.ts @@ -47,6 +47,12 @@ export class Version extends DSpaceObject { @autoserialize summary: string; + /** + * The name of the submitter of this version + */ + @autoserialize + submitterName: string; + /** * The Date this version was created */ diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index c2f076a5e5..a27cb6954b 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -3,8 +3,8 @@ */ export enum ViewMode { - ListElement = 'listElement', - GridElement = 'gridElement', - DetailedListElement = 'detailedListElement', - StandalonePage = 'standalonePage', + ListElement = 'list', + GridElement = 'grid', + DetailedListElement = 'detailed', + StandalonePage = 'standalone', } diff --git a/src/app/correlation-id/correlation-id.actions.ts b/src/app/correlation-id/correlation-id.actions.ts new file mode 100644 index 0000000000..d57d41a637 --- /dev/null +++ b/src/app/correlation-id/correlation-id.actions.ts @@ -0,0 +1,21 @@ +import { type } from '../shared/ngrx/type'; +import { Action } from '@ngrx/store'; + +export const CorrelationIDActionTypes = { + SET: type('dspace/core/correlationId/SET') +}; + +/** + * Action for setting a new correlation ID + */ +export class SetCorrelationIdAction implements Action { + type = CorrelationIDActionTypes.SET; + + constructor(public payload: string) { + } +} + +/** + * Type alias for all correlation ID actions + */ +export type CorrelationIdAction = SetCorrelationIdAction; diff --git a/src/app/correlation-id/correlation-id.reducer.spec.ts b/src/app/correlation-id/correlation-id.reducer.spec.ts new file mode 100644 index 0000000000..c784def1d9 --- /dev/null +++ b/src/app/correlation-id/correlation-id.reducer.spec.ts @@ -0,0 +1,23 @@ +import { correlationIdReducer } from './correlation-id.reducer'; +import { SetCorrelationIdAction } from './correlation-id.actions'; + +describe('correlationIdReducer', () => { + it('should set the correlatinId with SET action', () => { + const initialState = null; + const currentState = correlationIdReducer(initialState, new SetCorrelationIdAction('new ID')); + + expect(currentState).toBe('new ID'); + }); + + it('should leave correlatinId unchanged otherwise', () => { + const initialState = null; + + let currentState = correlationIdReducer(initialState, { type: 'unknown' } as any); + expect(currentState).toBe(null); + + currentState = correlationIdReducer(currentState, new SetCorrelationIdAction('new ID')); + currentState = correlationIdReducer(currentState, { type: 'unknown' } as any); + + expect(currentState).toBe('new ID'); + }); +}); diff --git a/src/app/correlation-id/correlation-id.reducer.ts b/src/app/correlation-id/correlation-id.reducer.ts new file mode 100644 index 0000000000..b7525b0b1c --- /dev/null +++ b/src/app/correlation-id/correlation-id.reducer.ts @@ -0,0 +1,27 @@ +import { + CorrelationIdAction, + CorrelationIDActionTypes, + SetCorrelationIdAction +} from './correlation-id.actions'; +import { AppState } from '../app.reducer'; + +const initialState = null; + +export const correlationIdSelector = (state: AppState) => state.correlationId; + +/** + * Reducer that handles actions to update the correlation ID + * @param {string} state the previous correlation ID (null if unset) + * @param {CorrelationIdAction} action the action to perform + * @return {string} the new correlation ID + */ +export const correlationIdReducer = (state = initialState, action: CorrelationIdAction): string => { + switch (action.type) { + case CorrelationIDActionTypes.SET: { + return (action as SetCorrelationIdAction).payload; + } + default: { + return state; + } + } +}; diff --git a/src/app/correlation-id/correlation-id.service.spec.ts b/src/app/correlation-id/correlation-id.service.spec.ts new file mode 100644 index 0000000000..64a4d1068a --- /dev/null +++ b/src/app/correlation-id/correlation-id.service.spec.ts @@ -0,0 +1,83 @@ +import { CorrelationIdService } from './correlation-id.service'; +import { CookieServiceMock } from '../shared/mocks/cookie.service.mock'; +import { UUIDService } from '../core/shared/uuid.service'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { appReducers, AppState, storeModuleConfig } from '../app.reducer'; +import { SetCorrelationIdAction } from './correlation-id.actions'; + +describe('CorrelationIdService', () => { + let service: CorrelationIdService; + + let cookieService; + let uuidService; + let store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(appReducers, storeModuleConfig), + ], + }).compileComponents(); + }); + + beforeEach(() => { + cookieService = new CookieServiceMock(); + uuidService = new UUIDService(); + store = TestBed.inject(Store) as MockStore; + service = new CorrelationIdService(cookieService, uuidService, store); + }); + + describe('getCorrelationId', () => { + it('should get from from store', () => { + expect(service.getCorrelationId()).toBe(null); + store.dispatch(new SetCorrelationIdAction('some value')); + expect(service.getCorrelationId()).toBe('some value'); + }); + }); + + + describe('initCorrelationId', () => { + const cookieCID = 'cookie CID'; + const storeCID = 'store CID'; + + it('should set cookie and store values to a newly generated value if neither ex', () => { + service.initCorrelationId(); + + expect(cookieService.get('CORRELATION-ID')).toBeTruthy(); + expect(service.getCorrelationId()).toBeTruthy(); + expect(cookieService.get('CORRELATION-ID')).toEqual(service.getCorrelationId()); + }); + + it('should set store value to cookie value if present', () => { + expect(service.getCorrelationId()).toBe(null); + + cookieService.set('CORRELATION-ID', cookieCID); + + service.initCorrelationId(); + + expect(cookieService.get('CORRELATION-ID')).toBe(cookieCID); + expect(service.getCorrelationId()).toBe(cookieCID); + }); + + it('should set cookie value to store value if present', () => { + store.dispatch(new SetCorrelationIdAction(storeCID)); + + service.initCorrelationId(); + + expect(cookieService.get('CORRELATION-ID')).toBe(storeCID); + expect(service.getCorrelationId()).toBe(storeCID); + }); + + it('should set store value to cookie value if both are present', () => { + cookieService.set('CORRELATION-ID', cookieCID); + store.dispatch(new SetCorrelationIdAction(storeCID)); + + service.initCorrelationId(); + + expect(cookieService.get('CORRELATION-ID')).toBe(cookieCID); + expect(service.getCorrelationId()).toBe(cookieCID); + }); + }); +}); diff --git a/src/app/correlation-id/correlation-id.service.ts b/src/app/correlation-id/correlation-id.service.ts new file mode 100644 index 0000000000..6f4b2a5341 --- /dev/null +++ b/src/app/correlation-id/correlation-id.service.ts @@ -0,0 +1,64 @@ +import { CookieService } from '../core/services/cookie.service'; +import { UUIDService } from '../core/shared/uuid.service'; +import { Store, select } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { isEmpty } from '../shared/empty.util'; +import { correlationIdSelector } from './correlation-id.reducer'; +import { take } from 'rxjs/operators'; +import { SetCorrelationIdAction } from './correlation-id.actions'; +import { Injectable } from '@angular/core'; + +/** + * Service to manage the correlation id, an id used to give context to server side logs + */ +@Injectable({ + providedIn: 'root' +}) +export class CorrelationIdService { + + constructor( + protected cookieService: CookieService, + protected uuidService: UUIDService, + protected store: Store, + ) { + } + + /** + * Initialize the correlation id based on the cookie or the ngrx store + */ + initCorrelationId(): void { + // first see of there's a cookie with a correlation-id + let correlationId = this.cookieService.get('CORRELATION-ID'); + + // if there isn't see if there's an ID in the store + if (isEmpty(correlationId)) { + correlationId = this.getCorrelationId(); + } + + // if no id was found, create a new id + if (isEmpty(correlationId)) { + correlationId = this.uuidService.generate(); + } + + // Store the correct id both in the store and as a cookie to ensure they're in sync + this.store.dispatch(new SetCorrelationIdAction(correlationId)); + this.cookieService.set('CORRELATION-ID', correlationId); + } + + /** + * Get the correlation id from the store + */ + getCorrelationId(): string { + let correlationId; + + this.store.pipe( + select(correlationIdSelector), + take(1) + ).subscribe((storeId: string) => { + // we can do this because ngrx selects are synchronous + correlationId = storeId; + }); + + return correlationId; + } +} diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html index fa4c06d36a..6d88c9761b 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html @@ -1,10 +1,10 @@ diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html index 71aeb79c35..b7cb645e31 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html @@ -1,10 +1,10 @@ diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html index 05db5b8702..988fb2d4b5 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html @@ -1,10 +1,10 @@ diff --git a/src/app/entity-groups/journal-entities/journal-entities.module.ts b/src/app/entity-groups/journal-entities/journal-entities.module.ts index e23a729d6a..3bf861e10d 100644 --- a/src/app/entity-groups/journal-entities/journal-entities.module.ts +++ b/src/app/entity-groups/journal-entities/journal-entities.module.ts @@ -19,6 +19,7 @@ import { JournalVolumeSearchResultGridElementComponent } from './item-grid-eleme import { JournalVolumeSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-volume/journal-volume-sidebar-search-list-element.component'; import { JournalIssueSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component'; import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component'; +import { ItemSharedModule } from '../../item-page/item-shared.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -45,6 +46,7 @@ const ENTRY_COMPONENTS = [ @NgModule({ imports: [ CommonModule, + ItemSharedModule, SharedModule ], declarations: [ @@ -54,7 +56,7 @@ const ENTRY_COMPONENTS = [ export class JournalEntitiesModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html index ae1d8c7510..d711ad7c18 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html @@ -1,10 +1,10 @@ diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index 2e91a6f1e7..721a22be08 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '../../shared/shared.module'; import { OrgUnitComponent } from './item-pages/org-unit/org-unit.component'; import { PersonComponent } from './item-pages/person/person.component'; @@ -27,6 +28,7 @@ import { ExternalSourceEntryListSubmissionElementComponent } from './submission/ import { OrgUnitSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/org-unit/org-unit-sidebar-search-list-element.component'; import { PersonSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component'; import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component'; +import { ItemSharedModule } from '../../item-page/item-shared.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -65,7 +67,9 @@ const COMPONENTS = [ @NgModule({ imports: [ CommonModule, - SharedModule + ItemSharedModule, + SharedModule, + NgbTooltipModule ], declarations: [ ...COMPONENTS, @@ -74,12 +78,12 @@ const COMPONENTS = [ export class ResearchEntitiesModule { /** * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during CSR otherwise + * which are not loaded during SSR otherwise */ static withEntryComponents() { return { ngModule: ResearchEntitiesModule, - providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) + providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })) }; } } diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html index 87a422e7db..c4e31d3d81 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -15,7 +15,7 @@