diff --git a/.browserslistrc b/.browserslistrc index f8a421c330..427441dc93 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -2,10 +2,16 @@ # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries +# For the full list of supported browsers by the Angular framework, please see: +# https://angular.io/guide/browser-support + # You can see what browsers were selected by your queries by running: # npx browserslist -> 0.5% -last 2 versions +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major versions +last 2 iOS major versions Firefox ESR -not IE 9-11 # For IE 9-11 support, remove 'not'. +not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..6d5aa89db7 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,222 @@ +{ + "root": true, + "plugins": [ + "@typescript-eslint", + "@angular-eslint/eslint-plugin", + "eslint-plugin-import", + "eslint-plugin-jsdoc", + "eslint-plugin-deprecation", + "eslint-plugin-unused-imports" + ], + "overrides": [ + { + "files": [ + "*.ts" + ], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./cypress/tsconfig.json" + ], + "createDefaultProgram": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:@angular-eslint/recommended", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "max-classes-per-file": [ + "error", + 1 + ], + "comma-dangle": [ + "off", + "always-multiline" + ], + "eol-last": [ + "error", + "always" + ], + "no-console": [ + "error", + { + "allow": [ + "log", + "warn", + "dir", + "timeLog", + "assert", + "clear", + "count", + "countReset", + "group", + "groupEnd", + "table", + "debug", + "info", + "dirxml", + "error", + "groupCollapsed", + "Console", + "profile", + "profileEnd", + "timeStamp", + "context" + ] + } + ], + "curly": "error", + "brace-style": [ + "error", + "1tbs", + { + "allowSingleLine": true + } + ], + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ], + "radix": "error", + "guard-for-in": "error", + "no-bitwise": "error", + "no-restricted-imports": "error", + "no-caller": "error", + "no-debugger": "error", + "no-redeclare": "error", + "no-eval": "error", + "no-fallthrough": "error", + "no-trailing-spaces": "error", + "space-infix-ops": "error", + "keyword-spacing": "error", + "no-var": "error", + "no-unused-expressions": [ + "error", + { + "allowTernary": true + } + ], + "prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) + "prefer-spread": "off", + "no-underscore-dangle": "off", + + // todo: disabled rules from eslint:recommended, consider re-enabling & fixing + "no-prototype-builtins": "off", + "no-useless-escape": "off", + "no-case-declarations": "off", + "no-extra-boolean-cast": "off", + + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "ds", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ds", + "style": "kebab-case" + } + ], + "@angular-eslint/pipe-prefix": [ + "error", + { + "prefixes": [ + "ds" + ] + } + ], + "@angular-eslint/no-attribute-decorator": "error", + "@angular-eslint/no-forward-ref": "error", + "@angular-eslint/no-output-native": "warn", + "@angular-eslint/no-output-on-prefix": "warn", + "@angular-eslint/no-conflicting-lifecycle": "warn", + + "@typescript-eslint/no-inferrable-types":[ + "error", + { + "ignoreParameters": true + } + ], + "@typescript-eslint/quotes": [ + "error", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ], + "@typescript-eslint/semi": "error", + "@typescript-eslint/no-shadow": "error", + "@typescript-eslint/dot-notation": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "property", + "format": null + } + ], + "@typescript-eslint/member-ordering": [ + "error", + { + "default": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "@typescript-eslint/type-annotation-spacing": "error", + "@typescript-eslint/unified-signatures": "error", + "@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/no-misused-promises": "warn", + "@typescript-eslint/restrict-plus-operands": "warn", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/require-await": "off", + + "deprecation/deprecation": "warn", + + "import/order": "off", + "import/no-deprecated": "warn" + } + }, + { + "files": [ + "*.html" + ], + "extends": [ + "plugin:@angular-eslint/template/recommended" + ], + "rules": { + // todo: re-enable & fix errors + "@angular-eslint/template/no-negated-async": "off", + "@angular-eslint/template/eqeqeq": "off" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..dfe0770424 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 539fd740ee..04d426d091 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job @@ -70,7 +70,10 @@ jobs: run: yarn install --frozen-lockfile - name: Run lint - run: yarn run lint + run: yarn run lint --quiet + + - name: Check for circular dependencies + run: yarn run check-circ-deps - name: Run build run: yarn run build:prod @@ -79,11 +82,11 @@ jobs: run: yarn run test:headless # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for Node v12 only) + # Upload coverage reports to Codecov (for one version of Node only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io uses: codecov/codecov-action@v2 - if: matrix.node-version == '12.x' + if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy @@ -128,6 +131,14 @@ jobs: name: e2e-test-screenshots path: cypress/screenshots + - name: Stop app (in case it stays up after e2e tests) + run: | + app_pid=$(lsof -t -i:4000) + if [[ ! -z $app_pid ]]; then + echo "App was still up! (PID: $app_pid)" + kill -9 $app_pid + fi + # Start up the app with SSR enabled (run in background) - name: Start app in SSR (server-side rendering) mode run: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 00ec2fa8f7..64303ca8bb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -31,6 +31,10 @@ jobs: # We turn off 'latest' tag by default. TAGS_FLAVOR: | latest=false + # Architectures / Platforms for which we will build Docker images + # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work. + # If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64. + PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }} steps: # https://github.com/actions/checkout @@ -41,6 +45,10 @@ jobs: - name: Setup Docker Buildx uses: docker/setup-buildx-action@v1 + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU emulation to build for multiple architectures + uses: docker/setup-qemu-action@v2 + # 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 @@ -70,6 +78,7 @@ jobs: with: context: . file: ./Dockerfile + platforms: ${{ env.PLATFORMS }} # 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' }} diff --git a/.gitignore b/.gitignore index 026110f222..bdd0d4e589 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.angular/cache /__build__ /__server_build__ /node_modules @@ -36,3 +37,5 @@ package-lock.json .env /nbproject/ + +junit.xml diff --git a/Dockerfile b/Dockerfile index 2d98971112..a7c1640d0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,9 @@ EXPOSE 4000 # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com # See, for example https://github.com/yarnpkg/yarn/issues/5540 RUN yarn install --network-timeout 300000 -CMD yarn run start:dev + +# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc). +# Listen / accept connections from all IP addresses. +# NOTE: At this time it is only possible to run Docker container in Production mode +# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485 +CMD yarn serve --host 0.0.0.0 diff --git a/README.md b/README.md index 74010f3c5c..837cb48004 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v12.x`, `v14.x` or `v16.x` and yarn == `v1.x` +- Ensure you're running node `v14.x` or `v16.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. @@ -101,7 +101,7 @@ Installing ### Configuring -Default configuration file is located in `config/` folder. +Default runtime configuration file is located in `config/` folder. These configurations can be changed without rebuilding the distribution. 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. @@ -167,6 +167,22 @@ These configuration sources are collected **at run time**, and written to `dist/ The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`. +#### Buildtime Configuring + +Buildtime configuration must defined before build in order to include in transpiled JavaScript. This is primarily for the server. These settings can be found under `src/environment/` folder. + +To override the default configuration values for development, create local file that override the build time parameters you need to change. + +- Create a new `environment.(dev or development).ts` file in `src/environment/` for a `development` environment; + +If needing to update default configurations values for production, update local file that override the build time parameters you need to change. + +- Update `environment.production.ts` file in `src/environment/` for a `production` environment; + +The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application. + +> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap. + #### Using environment variables in code To use environment variables in a UI component, use: @@ -183,7 +199,6 @@ or import { environment } from '../environment.ts'; ``` - Running the app --------------- @@ -193,7 +208,7 @@ After you have installed all dependencies you can now run the app. Run `yarn run When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload. -To build the app for production and start the server run: +To build the app for production and start the server (in one command) run: ```bash yarn start @@ -207,6 +222,10 @@ yarn run build:prod ``` This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`. +After building the app for production, it can be started by running: +```bash +yarn run serve:ssr +``` ### Running the application with Docker NOTE: At this time, we do not have production-ready Docker images for DSpace. @@ -268,11 +287,29 @@ 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, 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 +Before you can run e2e tests, two things are REQUIRED: +1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time. + * After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend. + * If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example: + ``` + DSPACE_REST_SSL = false + DSPACE_REST_HOST = localhost + DSPACE_REST_PORT = 8080 + ``` +2. Your backend MUST include our [Entities Test Data set](https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data). Some tests run against a specific Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. + * (Recommended) 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 + * Alternatively, the Entities Test Data set may be installed via a simple SQL import (e. g. `psql -U dspace < dspace7-entities-data.sql`). See instructions in link above. -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. +After performing the above setup, you can run the e2e tests using +``` +ng e2e +```` +NOTE: By default these tests will run against the REST API backend configured via environment variables or in `config.prod.yml`. If you'd rather it use `config.dev.yml`, just set the NODE_ENV environment variable like this: +``` +NODE_ENV=development ng e2e +``` + +The `ng e2e` command 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. #### Writing E2E Tests @@ -293,8 +330,11 @@ All E2E tests must be created under the `./cypress/integration/` folder, and mus * In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. * From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page. * Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector + * It's generally best not to rely on attributes like `class` and `id` in tests, as those are likely to change later on. Instead, you can add a `data-test` attribute to makes it clear that it's required for a test. * Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc. - * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. + * When running with server-side rendering enabled, the client first receives HTML without the JS; only once the page is rendered client-side do some elements (e.g. a button that toggles a Bootstrap dropdown) become fully interactive. This can trip up Cypress in some cases as it may try to `click` or `type` in an element that's not fully loaded yet, causing tests to fail. + * To work around this issue, define the attributes you use for Cypress selectors as `[attr.data-test]="'button' | ngBrowserOnly"`. This will only show the attribute in CSR HTML, forcing Cypress to wait until CSR is complete before interacting with the element. + * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. * Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly. * Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests. diff --git a/angular.json b/angular.json index a0a4cd8ea1..2ece0c5e7d 100644 --- a/angular.json +++ b/angular.json @@ -17,7 +17,6 @@ "build": { "builder": "@angular-builders/custom-webpack:browser", "options": { - "extractCss": true, "preserveSymlinks": true, "customWebpackConfig": { "path": "./webpack/webpack.browser.ts", @@ -64,19 +63,31 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + }, "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.production.ts" + }, + { + "replace": "src/config/store/devtools.ts", + "with": "src/config/store/devtools.prod.ts" } ], "optimization": true, "outputHashing": "all", - "extractCss": true, "namedChunks": false, "aot": true, "extractLicenses": true, @@ -104,6 +115,9 @@ "port": 4000 }, "configurations": { + "development": { + "browserTarget": "dspace-angular:build:development" + }, "production": { "browserTarget": "dspace-angular:build:production" } @@ -157,19 +171,6 @@ } } }, - "lint": { - "builder": "@angular-devkit/build-angular:tslint", - "options": { - "tsConfig": [ - "tsconfig.app.json", - "tsconfig.spec.json", - "cypress/tsconfig.json" - ], - "exclude": [ - "**/node_modules/**" - ] - } - }, "e2e": { "builder": "@cypress/schematic:cypress", "options": { @@ -197,6 +198,10 @@ "tsConfig": "tsconfig.server.json" }, "configurations": { + "development": { + "sourceMap": true, + "optimization": false + }, "production": { "sourceMap": false, "optimization": true, @@ -204,6 +209,10 @@ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.production.ts" + }, + { + "replace": "src/config/store/devtools.ts", + "with": "src/config/store/devtools.prod.ts" } ] } @@ -253,12 +262,22 @@ "watch": true, "headless": false } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } } } } }, "defaultProject": "dspace-angular", "cli": { - "analytics": false + "analytics": false, + "defaultCollection": "@angular-eslint/schematics" } -} \ No newline at end of file +} diff --git a/config/config.example.yml b/config/config.example.yml index ecb2a3cfb9..ae733e0be5 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -2,7 +2,8 @@ debug: false # Angular Universal server settings -# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg. +# NOTE: these settings define where Node.js will start your UI application. Therefore, these +# "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar) ui: ssl: false host: localhost @@ -15,7 +16,8 @@ ui: 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. +# NOTE: these settings define which (publicly available) REST API to use. They are usually +# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. rest: ssl: true host: api7.dspace.org @@ -148,6 +150,15 @@ languages: - code: fi label: Suomi active: true + - code: sv + label: Svenska + active: true + - code: tr + label: Türkçe + active: true + - code: bn + label: বাংলা + active: true # Browse-By Pages browseBy: @@ -158,10 +169,12 @@ browseBy: # 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 Config item: edit: undoTimeout: 10000 # 10 seconds + # Show the item access status label in items lists + showAccessStatuses: false # Collection Page Config collection: @@ -228,9 +241,20 @@ themes: rel: manifest href: assets/dspace/images/favicons/manifest.webmanifest +# The default bundles that should always be displayed as suggestions when you upload a new bundle +bundle: + - standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ] + # 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 + +# Whether the end user agreement is required before users use the repository. +# If enabled, the user will be required to accept the agreement before they can use the repository. +# And whether the privacy statement should exist or not. +info: + enableEndUserAgreement: true + enablePrivacyStatement: true diff --git a/cypress.json b/cypress.json index e06de8e4c5..80358eb6dd 100644 --- a/cypress.json +++ b/cypress.json @@ -6,5 +6,20 @@ "pluginsFile": "cypress/plugins/index.ts", "fixturesFolder": "cypress/fixtures", "baseUrl": "http://localhost:4000", - "retries": 2 + "retries": { + "runMode": 2, + "openMode": 0 + }, + "env": { + "DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com", + "DSPACE_TEST_ADMIN_PASSWORD": "dspace", + "DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4", + "DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200", + "DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067", + "DSPACE_TEST_SEARCH_TERM": "test", + "DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection", + "DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144", + "DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com", + "DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace" + } } \ No newline at end of file diff --git a/cypress/integration/homepage.spec.ts b/cypress/integration/homepage.spec.ts index ddde260bc7..8fdf61dbf7 100644 --- a/cypress/integration/homepage.spec.ts +++ b/cypress/integration/homepage.spec.ts @@ -16,8 +16,8 @@ describe('Homepage', () => { it('should have a working search box', () => { const queryString = 'test'; - cy.get('ds-search-form input[name="query"]').type(queryString); - cy.get('ds-search-form button.search-button').click(); + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); cy.url().should('include', '/search'); cy.url().should('include', 'query=' + encodeURI(queryString)); }); diff --git a/cypress/integration/login-modal.spec.ts b/cypress/integration/login-modal.spec.ts new file mode 100644 index 0000000000..fece28b425 --- /dev/null +++ b/cypress/integration/login-modal.spec.ts @@ -0,0 +1,126 @@ +import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support'; + +const page = { + openLoginMenu() { + // Click the "Log In" dropdown menu in header + cy.get('ds-themed-navbar [data-test="login-menu"]').click(); + }, + openUserMenu() { + // Once logged in, click the User menu in header + cy.get('ds-themed-navbar [data-test="user-menu"]').click(); + }, + submitLoginAndPasswordByPressingButton(email, password) { + // Enter email + cy.get('ds-themed-navbar [data-test="email"]').type(email); + // Enter password + cy.get('ds-themed-navbar [data-test="password"]').type(password); + // Click login button + cy.get('ds-themed-navbar [data-test="login-button"]').click(); + }, + submitLoginAndPasswordByPressingEnter(email, password) { + // In opened Login modal, fill out email & password, then click Enter + cy.get('ds-themed-navbar [data-test="email"]').type(email); + cy.get('ds-themed-navbar [data-test="password"]').type(password); + cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); + }, + submitLogoutByPressingButton() { + // This is the POST command that will actually log us out + cy.intercept('POST', '/server/api/authn/logout').as('logout'); + // Click logout button + cy.get('ds-themed-navbar [data-test="logout-button"]').click(); + // Wait until above POST command responds before continuing + // (This ensures next action waits until logout completes) + cy.wait('@logout'); + } +}; + +describe('Login Modal', () => { + it('should login when clicking button & stay on same page', () => { + const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; + cy.visit(ENTITYPAGE); + + // Login menu should exist + cy.get('ds-log-in').should('exist'); + + // Login, and the tag should no longer exist + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); + + page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.get('ds-log-in').should('not.exist'); + + // Verify we are still on the same page + cy.url().should('include', ENTITYPAGE); + + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); + + it('should login when clicking enter key & stay on same page', () => { + cy.visit('/home'); + + // Open login menu in header & verify tag is visible + page.openLoginMenu(); + cy.get('.form-login').should('be.visible'); + + // Login, and the tag should no longer exist + page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.get('.form-login').should('not.exist'); + + // Verify we are still on homepage + cy.url().should('include', '/home'); + + // Open user menu, verify user menu & logout button now available + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + cy.get('ds-log-out').should('be.visible'); + }); + + it('should support logout', () => { + // First authenticate & access homepage + cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.visit('/'); + + // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist + cy.get('ds-log-in').should('not.exist'); + cy.get('ds-log-out').should('exist'); + + // Click logout button + page.openUserMenu(); + page.submitLogoutByPressingButton(); + + // Verify ds-log-in tag now exists + cy.get('ds-log-in').should('exist'); + cy.get('ds-log-out').should('not.exist'); + }); + + it('should allow new user registration', () => { + cy.visit('/'); + + page.openLoginMenu(); + + // Registration link should be visible + cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); + + // Click registration link & you should go to registration page + cy.get('ds-themed-navbar [data-test="register"]').click(); + cy.location('pathname').should('eq', '/register'); + cy.get('ds-register-email').should('exist'); + }); + + it('should allow forgot password', () => { + cy.visit('/'); + + page.openLoginMenu(); + + // Forgot password link should be visible + cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); + + // Click link & you should go to Forgot Password page + cy.get('ds-themed-navbar [data-test="forgot"]').click(); + cy.location('pathname').should('eq', '/forgot'); + cy.get('ds-forgot-email').should('exist'); + }); +}); diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/integration/my-dspace.spec.ts new file mode 100644 index 0000000000..fa923dbcbc --- /dev/null +++ b/cypress/integration/my-dspace.spec.ts @@ -0,0 +1,149 @@ +import { Options } from 'cypress-axe'; +import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('My DSpace page', () => { + it('should display recent submissions and pass accessibility tests', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + cy.visit('/mydspace'); + + cy.get('ds-my-dspace-page').should('exist'); + + // At least one recent submission should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + + // Click each filter toggle to open *every* filter + // (As we want to scan filter section for accessibility issues as well) + cy.get('.filter-toggle').click({ multiple: true }); + + // Analyze for accessibility issues + testA11y( + { + include: ['ds-my-dspace-page'], + exclude: [ + ['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175 + ], + }, + { + rules: { + // Search filters fail these two "moderate" impact rules + 'heading-order': { enabled: false }, + 'landmark-unique': { enabled: false } + } + } as Options + ); + }); + + it('should have a working detailed view that passes accessibility tests', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + cy.visit('/mydspace'); + + cy.get('ds-my-dspace-page').should('exist'); + + // Click button in sidebar to display detailed view + cy.get('ds-search-sidebar [data-test="detail-view"]').click(); + + cy.get('ds-object-detail').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-my-dspace-page', + { + rules: { + // Search filters fail these two "moderate" impact rules + 'heading-order': { enabled: false }, + 'landmark-unique': { enabled: false } + } + } as Options + ); + }); + + // NOTE: Deleting existing submissions is exercised by submission.spec.ts + it('should let you start a new submission & edit in-progress submissions', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.visit('/mydspace'); + + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Item" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="none"]').click(); + + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); + + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); + + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click(); + + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); + + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + + // Now that we've created a submission, we'll test that we can go back and Edit it. + // Get our Submission URL, to parse out the ID of this new submission + cy.location().then(fullUrl => { + // This will be the full path (/workspaceitems/[id]/edit) + const path = fullUrl.pathname; + // Split on the slashes + const subpaths = path.split('/'); + // Part 2 will be the [id] of the submission + const id = subpaths[2]; + + // Click the "Save for Later" button to save this submission + cy.get('ds-submission-form-footer [data-test="save-for-later"]').click(); + + // "Save for Later" should send us to MyDSpace + cy.url().should('include', '/mydspace'); + + // Close any open notifications, to make sure they don't get in the way of next steps + cy.get('[data-dismiss="alert"]').click({multiple: true}); + + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // On MyDSpace, find the submission we just created via its ID + cy.get('[data-test="search-box"]').type(id); + cy.get('[data-test="search-button"]').click(); + + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + + // Click the Edit button for this in-progress submission + cy.get('#edit_' + id).click(); + + // Should send us back to the submission form + cy.url().should('include', '/workspaceitems/' + id + '/edit'); + + // Discard our new submission by clicking Discard in Submission form & confirming + cy.get('ds-submission-form-footer [data-test="discard"]').click(); + cy.get('button#discard_submit').click(); + + // Discarding should send us back to MyDSpace + cy.url().should('include', '/mydspace'); + }); + }); + + it('should let you import from external sources', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.visit('/mydspace'); + + // Open the New Import dropdown + cy.get('button[data-test="import-dropdown"]').click(); + // Click on the "Item" type in that dropdown + cy.get('#importControlsDropdownMenu button[title="none"]').click(); + + // New URL should include /import-external, as we've moved to the import page + cy.url().should('include', '/import-external'); + + // The external import searchbox should be visible + cy.get('ds-submission-import-external-searchbar').should('be.visible'); + }); + +}); diff --git a/cypress/integration/search-navbar.spec.ts b/cypress/integration/search-navbar.spec.ts index 19a3d56ed4..babd9b9dfd 100644 --- a/cypress/integration/search-navbar.spec.ts +++ b/cypress/integration/search-navbar.spec.ts @@ -1,49 +1,66 @@ +import { TEST_SEARCH_TERM } from 'cypress/support'; + const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass - cy.get('.navbar-container #search-navbar-container form a').click(); + cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); // Fill out a query in input that appears - cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type(query); + cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); }, submitQueryByPressingEnter() { - cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type('{enter}'); + cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); }, submitQueryByPressingIcon() { - cy.get('.navbar-container #search-navbar-container form .submit-icon').click(); + cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); } }; describe('Search from Navigation Bar', () => { // NOTE: these tests currently assume this query will return results! - const query = 'test'; + const query = TEST_SEARCH_TERM; it('should go to search page with correct query if submitted (from home)', () => { cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search page.fillOutQueryInNavBar(query); page.submitQueryByPressingEnter(); // New URL should include query param cy.url().should('include', 'query=' + query); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); // At least one search result should be displayed - cy.get('ds-item-search-result-list-element').should('be.visible'); + cy.get('[data-test="list-object"]').should('be.visible'); }); it('should go to search page with correct query if submitted (from search)', () => { cy.visit('/search'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search page.fillOutQueryInNavBar(query); page.submitQueryByPressingEnter(); // New URL should include query param cy.url().should('include', 'query=' + query); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); // At least one search result should be displayed - cy.get('ds-item-search-result-list-element').should('be.visible'); + cy.get('[data-test="list-object"]').should('be.visible'); }); it('should allow user to also submit query by clicking icon', () => { cy.visit('/'); + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // Run the search page.fillOutQueryInNavBar(query); page.submitQueryByPressingIcon(); // New URL should include query param cy.url().should('include', 'query=' + query); + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); // At least one search result should be displayed - cy.get('ds-item-search-result-list-element').should('be.visible'); + cy.get('[data-test="list-object"]').should('be.visible'); }); }); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index 859c765d2e..623c370c56 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -1,34 +1,30 @@ import { Options } from 'cypress-axe'; +import { TEST_SEARCH_TERM } from 'cypress/support'; import { testA11y } from 'cypress/support/utils'; describe('Search Page', () => { - // unique ID of the search form (for selecting specific elements below) - const SEARCHFORM_ID = '#search-form'; - - it('should contain query value when navigating to page with query parameter', () => { - const queryString = 'test query'; - cy.visit('/search?query=' + queryString); - cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString); - }); - it('should redirect to the correct url when query was set and submit button was triggered', () => { const queryString = 'Another interesting query string'; cy.visit('/search'); // Type query in searchbox & click search button - cy.get(SEARCHFORM_ID + ' input[name="query"]').type(queryString); - cy.get(SEARCHFORM_ID + ' button.search-button').click(); + cy.get('[data-test="search-box"]').type(queryString); + cy.get('[data-test="search-button"]').click(); cy.url().should('include', 'query=' + encodeURI(queryString)); }); - it('should pass accessibility tests', () => { - cy.visit('/search'); + it('should load results and pass accessibility tests', () => { + cy.visit('/search?query=' + TEST_SEARCH_TERM); + cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); // tag must be loaded cy.get('ds-search-page').should('exist'); + // At least one search result should be displayed + cy.get('[data-test="list-object"]').should('be.visible'); + // Click each filter toggle to open *every* filter // (As we want to scan filter section for accessibility issues as well) - cy.get('.filter-toggle').click({ multiple: true }); + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); // Analyze for accessibility issues testA11y( @@ -48,16 +44,18 @@ describe('Search Page', () => { ); }); - it('should pass accessibility tests in Grid view', () => { - cy.visit('/search'); + it('should have a working grid view that passes accessibility tests', () => { + cy.visit('/search?query=' + TEST_SEARCH_TERM); - // 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?view=grid"] > .fas').click(); + // Click button in sidebar to display grid view + cy.get('ds-search-sidebar [data-test="grid-view"]').click(); // tag must be loaded cy.get('ds-search-page').should('exist'); + // At least one grid object (card) should be displayed + cy.get('[data-test="grid-object"]').should('be.visible'); + // Analyze for accessibility issues testA11y('ds-search-page', { diff --git a/cypress/integration/submission.spec.ts b/cypress/integration/submission.spec.ts new file mode 100644 index 0000000000..009c50115b --- /dev/null +++ b/cypress/integration/submission.spec.ts @@ -0,0 +1,135 @@ +import { Options } from 'cypress-axe'; +import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support'; +import { testA11y } from 'cypress/support/utils'; + +describe('New Submission page', () => { + // NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts + + it('should create a new submission when using /submit path & pass accessibility', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + // Test that calling /submit with collection & entityType will create a new submission + cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // Should redirect to /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); + + // A Collection menu button should exist & it's value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + + // 4 sections should be visible by default + cy.get('div#section_traditionalpageone').should('be.visible'); + cy.get('div#section_traditionalpagetwo').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); + + // Discard button should work + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); + + it('should block submission & show errors if required fields are missing', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + // Create a new submission + cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // Attempt an immediate deposit without filling out any fields + cy.get('button#deposit').click(); + + // A warning alert should display. + cy.get('ds-notification div.alert-success').should('not.exist'); + cy.get('ds-notification div.alert-warning').should('be.visible'); + + // First section should have an exclamation error in the header + // (as it has required fields) + cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible'); + + // Title field should have class "is-invalid" applied, as it's required + cy.get('input#dc_title').should('have.class', 'is-invalid'); + + // Date Year field should also have "is-valid" class + cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid'); + + // FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button. + // Get our Submission URL, to parse out the ID of this submission + cy.location().then(fullUrl => { + // This will be the full path (/workspaceitems/[id]/edit) + const path = fullUrl.pathname; + // Split on the slashes + const subpaths = path.split('/'); + // Part 2 will be the [id] of the submission + const id = subpaths[2]; + + // Even though form is incomplete, the "Save for Later" button should still work + cy.get('button#saveForLater').click(); + + // "Save for Later" should send us to MyDSpace + cy.url().should('include', '/mydspace'); + + // A success alert should be visible + cy.get('ds-notification div.alert-success').should('be.visible'); + // Now, dismiss any open alert boxes (may be multiple, as tests run quickly) + cy.get('[data-dismiss="alert"]').click({multiple: true}); + + // This is the GET command that will actually run the search + cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results'); + // On MyDSpace, find the submission we just saved via its ID + cy.get('[data-test="search-box"]').type(id); + cy.get('[data-test="search-button"]').click(); + + // Wait for search results to come back from the above GET command + cy.wait('@search-results'); + + // Delete our created submission & confirm deletion + cy.get('button#delete_' + id).click(); + cy.get('button#delete_confirm').click(); + }); + }); + + it('should allow for deposit if all required fields completed & file uploaded', () => { + cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + + // Create a new submission + cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + + // Fill out all required fields (Title, Date) + cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); + cy.get('input#dc_date_issued_year').type('2022'); + + // Confirm the required license by checking checkbox + // (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own ) + cy.get('input#granted').check( {force: true} ); + + // Before using Cypress drag & drop, we have to manually trigger the "dragover" event. + // This ensures our UI displays the dropzone that covers the entire submission page. + // (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger) + cy.get('ds-uploader').trigger('dragover'); + + // This is the POST command that will upload the file + cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload'); + + // Upload our DSpace logo via drag & drop onto submission form + // cy.get('div#section_upload') + cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', { + action: 'drag-drop' + }); + + // Wait for upload to complete before proceeding + cy.wait('@upload'); + // Close the upload success notice + cy.get('[data-dismiss="alert"]').click({multiple: true}); + + // Wait for deposit button to not be disabled & click it. + cy.get('button#deposit').should('not.be.disabled').click(); + + // No warnings should exist. Instead, just successful deposit alert is displayed + cy.get('ds-notification div.alert-warning').should('not.exist'); + cy.get('ds-notification div.alert-success').should('be.visible'); + }); + +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index c6eb874232..ead38afb92 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,15 +1,34 @@ +const fs = require('fs'); + // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // For more info, visit https://on.cypress.io/plugins-api module.exports = (on, config) => { - // Define "log" and "table" tasks, used for logging accessibility errors during CI - // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file on('task', { + // Define "log" and "table" tasks, used for logging accessibility errors during CI + // Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file log(message: string) { console.log(message); return null; }, table(message: string) { console.table(message); + return null; + }, + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + readUIConfig() { + // Check if we have a config.json in the src/assets. If so, use that. + // This is where it's written when running "ng e2e" or "yarn serve" + if (fs.existsSync('./src/assets/config.json')) { + return fs.readFileSync('./src/assets/config.json', 'utf8'); + // Otherwise, check the dist/browser/assets + // This is where it's written when running "serve:ssr", which is what CI uses to start the frontend + } else if (fs.existsSync('./dist/browser/assets/config.json')) { + return fs.readFileSync('./dist/browser/assets/config.json', 'utf8'); + } + return null; } }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index af1f44a0fc..30951d46f1 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,43 +1,83 @@ // *********************************************** -// This example namespace declaration will help -// with Intellisense and code completion in your -// IDE or Text Editor. +// This File is for Custom Cypress commands. +// See docs at https://docs.cypress.io/api/cypress-api/custom-commands // *********************************************** -// declare namespace Cypress { -// interface Chainable { -// customCommand(param: any): typeof customCommand; -// } -// } -// -// function customCommand(param: any): void { -// console.warn(param); -// } -// -// NOTE: You can use it like so: -// Cypress.Commands.add('customCommand', customCommand); -// -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + +import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; +import { FALLBACK_TEST_REST_BASE_URL } from '.'; + +// Declare Cypress namespace to help with Intellisense & code completion in IDEs +// ALL custom commands MUST be listed here for code completion to work +// tslint:disable-next-line:no-namespace +declare global { + namespace Cypress { + interface Chainable { + /** + * Login to backend before accessing the next page. Ensures that the next + * call to "cy.visit()" will be authenticated as this user. + * @param email email to login as + * @param password password to login as + */ + login(email: string, password: string): typeof login; + } + } +} + +/** + * Login user via REST API directly, and pass authentication token to UI via + * the UI's dsAuthInfo cookie. + * @param email email to login as + * @param password password to login as + */ +function login(email: string, password: string): void { + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + cy.task('readUIConfig').then((str: string) => { + // Parse config into a JSON object + const config = JSON.parse(str); + + // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. + let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; + if (!config.rest.baseUrl) { + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + } else { + console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl); + baseRestUrl = config.rest.baseUrl; + } + + // To login via REST, first we have to do a GET to obtain a valid CSRF token + cy.request( baseRestUrl + '/api/authn/status' ) + .then((response) => { + // We should receive a CSRF token returned in a response header + expect(response.headers).to.have.property('dspace-xsrf-token'); + const csrfToken = response.headers['dspace-xsrf-token']; + + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { 'X-XSRF-TOKEN' : csrfToken}, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password } + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); + + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + }); + }); + + }); +} +// Add as a Cypress command (i.e. assign to 'cy.login') +Cypress.Commands.add('login', login); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index e8b10b9cfb..024b46cdde 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -13,14 +13,51 @@ // https://on.cypress.io/configuration // *********************************************************** -// When a command from ./commands is ready to use, import with `import './commands'` syntax -// import './commands'; +// Import all custom Commands (from commands.ts) for all tests +import './commands'; // Import Cypress Axe tools for all tests // https://github.com/component-driven/cypress-axe import 'cypress-axe'; +// Runs once before the first test in each "block" +beforeEach(() => { + // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie + // This just ensures it doesn't get in the way of matching other objects in the page. + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true}'); +}); + +// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. +// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. +// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ +afterEach(() => { + cy.window().then((win) => { + win.location.href = 'about:blank'; + }); +}); + + // Global constants used in tests -export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200'; -export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4'; -export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; +// May be overridden in our cypress.json config file using specified environment variables. +// Default values listed here are all valid for the Demo Entities Data set available at +// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data +// (This is the data set used in our CI environment) + +// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL +// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts +export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; + +// Admin account used for administrative tests +export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com'; +export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; +// Community/collection/publication used for view/edit tests +export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200'; +export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4'; +export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; +// Search term (should return results) used in search tests +export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test'; +// Collection used for submission tests +export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection'; +export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144'; +export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com'; +export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; diff --git a/docker/README.md b/docker/README.md index a2f4ef3362..1a9fee0a81 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,93 +1,95 @@ -# Docker Compose files - -*** -: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. -- docker-compose-rest.yml - - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes -- docker-compose-ci.yml - - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. -- cli.yml - - 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. - - -## To refresh / pull DSpace images from Dockerhub -``` -docker-compose -f docker/docker-compose.yml pull -``` - -## To build DSpace images using code in your branch -``` -docker-compose -f docker/docker-compose.yml build -``` - -## To start DSpace (REST and Angular) from your branch - -``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d -``` - -## Run DSpace REST and DSpace Angular from local branches. -_The system will be started in 2 steps. Each step shares the same docker network._ - -From DSpace/DSpace (build as needed) -``` -docker-compose -p d7 up -d -``` - -From DSpace/DSpace-angular -``` -docker-compose -p d7 -f docker/docker-compose.yml up -d -``` - -## Ingest test data from AIPDIR - -Create an administrator -``` -docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en -``` - -Load content from AIP files -``` -docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli -``` - -## Alternative Ingest - Use Entities dataset -_Delete your docker volumes or use a unique project (-p) name_ - -Start DSpace with Database Content from a database dump -``` -docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d -``` - -Load assetstore content and trigger a re-index of the repository -``` -docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli -``` - -## End to end testing of the rest api (runs in travis). -_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ - -``` -docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d -``` +# Docker Compose files + +*** +:warning: **THESE IMAGES ARE NOT PRODUCTION READY** The below Docker Compose images/resources were built for development/testing only. Therefore, they may not be fully secured or up-to-date, and should not be used in production. + +If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images 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. +- docker-compose-rest.yml + - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes +- docker-compose-ci.yml + - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. +- cli.yml + - 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. + + +## To refresh / pull DSpace images from Dockerhub +``` +docker-compose -f docker/docker-compose.yml pull +``` + +## To build DSpace images using code in your branch +``` +docker-compose -f docker/docker-compose.yml build +``` + +## To start DSpace (REST and Angular) from your branch + +``` +docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d +``` + +## Run DSpace REST and DSpace Angular from local branches. +_The system will be started in 2 steps. Each step shares the same docker network._ + +From DSpace/DSpace (build as needed) +``` +docker-compose -p d7 up -d +``` + +From DSpace/DSpace-angular +``` +docker-compose -p d7 -f docker/docker-compose.yml up -d +``` + +## Ingest test data from AIPDIR + +Create an administrator +``` +docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en +``` + +Load content from AIP files +``` +docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli +``` + +## Alternative Ingest - Use Entities dataset +_Delete your docker volumes or use a unique project (-p) name_ + +Start DSpace with Database Content from a database dump +``` +docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d +``` + +Load assetstore content and trigger a re-index of the repository +``` +docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli +``` + +## End to end testing of the rest api (runs in travis). +_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ + +``` +docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d +``` diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index c2846286d7..40e4974c7c 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -35,6 +35,6 @@ services: tar xvfz /tmp/assetstore.tar.gz fi - /dspace/bin/dspace index-discovery + /dspace/bin/dspace index-discovery -b /dspace/bin/dspace oai import /dspace/bin/dspace oai clean-cache diff --git a/docker/db.entities.yml b/docker/db.entities.yml index 818d14877c..6473bf2e38 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -20,12 +20,12 @@ services: environment: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql + - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml # This 'sed' command inserts the sample configurations specific to the Entities data set, see: # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 @@ -35,7 +35,7 @@ services: - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored sed -i '/name-map collection-handle="default".*/a \\n \ \ \ diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index a895314a17..dbe9500499 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -46,14 +46,14 @@ services: - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. Finally, start Tomcat entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored catalina.sh run # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data @@ -63,7 +63,7 @@ services: # This LOADSQL should be kept in sync with the LOADSQL in # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql + LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: diff --git a/karma.conf.js b/karma.conf.js index 24cd067fd1..8418312b1a 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -22,7 +22,7 @@ module.exports = function (config) { reports: ['html', 'lcovonly', 'text-summary'], fixWebpackSourcePaths: true }, - reporters: ['mocha', 'kjhtml'], + reporters: ['mocha', 'kjhtml', 'coverage-istanbul'], mochaReporter: { ignoreSkipped: true, output: 'autowatch' diff --git a/package.json b/package.json index 278afdf6c3..32832460a2 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "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", + "preserve": "yarn base-href", + "serve": "ng serve --configuration development", "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", - "build": "ng build", + "build": "ng build --configuration development", "build:stats": "ng build --stats-json", "build:prod": "yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", @@ -36,7 +37,9 @@ "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "cypress:open": "cypress open", "cypress:run": "cypress run", - "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts" + "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts", + "base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts", + "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./" }, "browser": { "fs": false, @@ -47,33 +50,37 @@ "private": true, "resolutions": { "minimist": "^1.2.5", - "webdriver-manager": "^12.1.8" + "webdriver-manager": "^12.1.8", + "ts-node": "10.2.1" }, "dependencies": { - "@angular/animations": "~11.2.14", - "@angular/cdk": "^11.2.13", - "@angular/common": "~11.2.14", - "@angular/compiler": "~11.2.14", - "@angular/core": "~11.2.14", - "@angular/forms": "~11.2.14", - "@angular/localize": "11.2.14", - "@angular/platform-browser": "~11.2.14", - "@angular/platform-browser-dynamic": "~11.2.14", - "@angular/platform-server": "~11.2.14", - "@angular/router": "~11.2.14", - "@kolkov/ngx-gallery": "^1.2.3", - "@ng-bootstrap/ng-bootstrap": "9.1.3", - "@ng-dynamic-forms/core": "^13.0.0", - "@ng-dynamic-forms/ui-ng-bootstrap": "^13.0.0", - "@ngrx/effects": "^11.1.1", - "@ngrx/router-store": "^11.1.1", - "@ngrx/store": "^11.1.1", - "@nguniversal/express-engine": "11.2.1", + "@angular/animations": "~13.2.6", + "@angular/cdk": "^13.2.6", + "@angular/common": "~13.2.6", + "@angular/compiler": "~13.2.6", + "@angular/core": "~13.2.6", + "@angular/forms": "~13.2.6", + "@angular/localize": "13.2.6", + "@angular/platform-browser": "~13.2.6", + "@angular/platform-browser-dynamic": "~13.2.6", + "@angular/platform-server": "~13.2.6", + "@angular/router": "~13.2.6", + "@babel/runtime": "^7.17.2", + "@kolkov/ngx-gallery": "^2.0.1", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@ng-bootstrap/ng-bootstrap": "^11.0.0", + "@ng-dynamic-forms/core": "^15.0.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0", + "@ngrx/effects": "^13.0.2", + "@ngrx/router-store": "^13.0.2", + "@ngrx/store": "^13.0.2", + "@nguniversal/express-engine": "^13.0.2", "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", "angular-idle-preload": "3.0.0", - "angular2-text-mask": "9.0.0", - "angulartics2": "^10.0.0", + "angulartics2": "^12.0.0", + "axios": "^0.27.2", "bootstrap": "4.3.1", "caniuse-lite": "^1.0.30001165", "cerialize": "0.1.18", @@ -100,9 +107,9 @@ "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", - "moment": "^2.29.1", + "moment": "^2.29.4", "morgan": "^1.10.0", - "ng-mocks": "11.11.2", + "ng-mocks": "^13.1.1", "ng2-file-upload": "1.4.0", "ng2-nouislider": "^1.8.3", "ngx-infinite-scroll": "^10.0.1", @@ -111,27 +118,35 @@ "ngx-sortablejs": "^11.1.0", "nouislider": "^14.6.3", "pem": "1.14.4", - "postcss-cli": "^8.3.0", + "postcss-cli": "^9.1.0", + "prop-types": "^15.7.2", + "react-copy-to-clipboard": "^5.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^6.6.3", + "rxjs": "^7.5.5", "sortablejs": "1.13.0", "tslib": "^2.0.0", - "url-parse": "^1.5.3", + "url-parse": "^1.5.6", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "^0.10.3" + "zone.js": "~0.11.5", + "ngx-ui-switch": "^11.0.1" }, "devDependencies": { - "@angular-builders/custom-webpack": "10.0.1", - "@angular-devkit/build-angular": "~0.1102.15", - "@angular/cli": "~11.2.15", - "@angular/compiler-cli": "~11.2.14", - "@angular/language-service": "~11.2.14", + "@angular-builders/custom-webpack": "~13.1.0", + "@angular-devkit/build-angular": "~13.2.6", + "@angular-eslint/builder": "13.1.0", + "@angular-eslint/eslint-plugin": "13.1.0", + "@angular-eslint/eslint-plugin-template": "13.1.0", + "@angular-eslint/schematics": "13.1.0", + "@angular-eslint/template-parser": "13.1.0", + "@angular/cli": "~13.2.6", + "@angular/compiler-cli": "~13.2.6", + "@angular/language-service": "~13.2.6", "@cypress/schematic": "^1.5.0", "@fortawesome/fontawesome-free": "^5.5.0", - "@ngrx/store-devtools": "^11.1.1", - "@ngtools/webpack": "10.2.3", - "@nguniversal/builders": "~11.2.1", + "@ngrx/store-devtools": "^13.0.2", + "@ngtools/webpack": "^13.2.6", + "@nguniversal/builders": "^13.0.2", "@types/deep-freeze": "0.1.2", "@types/express": "^4.17.9", "@types/file-saver": "^2.0.1", @@ -140,36 +155,44 @@ "@types/js-cookie": "2.2.6", "@types/lodash": "^4.14.165", "@types/node": "^14.14.9", + "@typescript-eslint/eslint-plugin": "5.11.0", + "@typescript-eslint/parser": "5.11.0", "axe-core": "^4.3.3", - "codelyzer": "^6.0.0", - "compression-webpack-plugin": "^3.0.1", + "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "css-loader": "3.4.0", - "cssnano": "^4.1.10", - "cypress": "8.6.0", - "cypress-axe": "^0.13.0", + "css-loader": "^6.2.0", + "css-minimizer-webpack-plugin": "^3.4.1", + "cssnano": "^5.0.6", + "cypress": "9.5.1", + "cypress-axe": "^0.14.0", "debug-loader": "^0.0.1", "deep-freeze": "0.0.1", "dotenv": "^8.2.0", + "eslint": "^8.2.0", + "eslint-plugin-deprecation": "^1.3.2", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsdoc": "^38.0.6", + "eslint-plugin-unused-imports": "^2.0.0", + "express-static-gzip": "^2.1.5", "fork-ts-checker-webpack-plugin": "^6.0.3", "html-loader": "^1.3.2", - "html-webpack-plugin": "^4.5.0", - "jasmine-core": "~3.6.0", - "jasmine-marbles": "0.6.0", + "jasmine-core": "^3.8.0", + "jasmine-marbles": "0.9.2", "jasmine-spec-reporter": "~5.0.0", - "karma": "^5.2.3", + "karma": "^6.3.14", "karma-chrome-launcher": "~3.1.0", "karma-coverage-istanbul-reporter": "~3.0.2", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", + "ngx-mask": "^13.1.7", "nodemon": "^2.0.15", - "optimize-css-assets-webpack-plugin": "^5.0.4", - "postcss-apply": "0.11.0", - "postcss-import": "^12.0.1", - "postcss-loader": "^3.0.0", - "postcss-preset-env": "6.7.0", + "postcss": "^8.1", + "postcss-apply": "0.12.0", + "postcss-import": "^14.0.0", + "postcss-loader": "^4.0.3", + "postcss-preset-env": "^7.4.2", "postcss-responsive-type": "1.0.0", "protractor": "^7.0.0", "protractor-istanbul-plugin": "2.0.0", @@ -177,16 +200,16 @@ "react": "^16.14.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "rxjs-spy": "^7.5.3", + "rxjs-spy": "^8.0.2", + "sass": "~1.32.6", + "sass-loader": "^12.6.0", "sass-resources-loader": "^2.1.1", - "script-ext-html-webpack-plugin": "2.1.5", - "string-replace-loader": "^2.3.0", + "string-replace-loader": "^3.1.0", "terser-webpack-plugin": "^2.3.1", "ts-loader": "^5.2.0", "ts-node": "^8.10.2", - "tslint": "^6.1.3", - "typescript": "~4.0.5", - "webpack": "^4.44.2", + "typescript": "~4.5.5", + "webpack": "^5.69.1", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" diff --git a/scripts/base-href.ts b/scripts/base-href.ts new file mode 100644 index 0000000000..aee547b46d --- /dev/null +++ b/scripts/base-href.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import { join } from 'path'; + +import { AppConfig } from '../src/config/app-config.interface'; +import { buildAppConfig } from '../src/config/config.server'; + +/** + * Script to set baseHref as `ui.nameSpace` for development mode. Adds `baseHref` to angular.json build options. + * + * Usage (see package.json): + * + * yarn base-href + */ + +const appConfig: AppConfig = buildAppConfig(); + +const angularJsonPath = join(process.cwd(), 'angular.json'); + +if (!fs.existsSync(angularJsonPath)) { + console.error(`Error:\n${angularJsonPath} does not exist\n`); + process.exit(1); +} + +try { + const angularJson = require(angularJsonPath); + + const baseHref = `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`; + + console.log(`Setting baseHref to ${baseHref} in angular.json`); + + angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref; + + fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n'); +} catch (e) { + console.error(e); +} diff --git a/scripts/env-to-yaml.ts b/scripts/env-to-yaml.ts index edcdfd90b4..c2dd1cf0ca 100644 --- a/scripts/env-to-yaml.ts +++ b/scripts/env-to-yaml.ts @@ -24,7 +24,7 @@ if (!fs.existsSync(envFullPath)) { } try { - const env = require(envFullPath); + const env = require(envFullPath).environment; const config = yaml.dump(env); if (args[1]) { diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index ad8a712f21..96ba0d4010 100755 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -1,4 +1,5 @@ -import { projectRoot} from '../webpack/helpers'; +import { projectRoot } from '../webpack/helpers'; + const commander = require('commander'); const fs = require('fs'); const JSON5 = require('json5'); @@ -119,7 +120,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { outputChunks.forEach(function (chunk) { progressBar.increment(); chunk.split("\n").forEach(function (line) { - file.write(" " + line + "\n"); + file.write((line === '' ? '' : ` ${line}`) + "\n"); }); }); file.write("\n}"); @@ -192,7 +193,10 @@ function createNewChunkComparingSourceAndTarget(correspondingTargetChunk, source const targetList = correspondingTargetChunk.split("\n"); const oldKeyValueInTargetComments = getSubStringWithRegex(correspondingTargetChunk, "\\s*\\/\\/\\s*\".*"); - const keyValueTarget = targetList[targetList.length - 1]; + let keyValueTarget = targetList[targetList.length - 1]; + if (!keyValueTarget.endsWith(",")) { + keyValueTarget = keyValueTarget + ","; + } if (oldKeyValueInTargetComments != null) { const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0]; diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts index aa3b64f62b..b2a3ebd1af 100644 --- a/scripts/test-rest.ts +++ b/scripts/test-rest.ts @@ -3,7 +3,7 @@ import * as https from 'https'; import { AppConfig } from '../src/config/app-config.interface'; import { buildAppConfig } from '../src/config/config.server'; - + const appConfig: AppConfig = buildAppConfig(); /** diff --git a/server.ts b/server.ts index da3b877bc1..9fe03fe5b5 100644 --- a/server.ts +++ b/server.ts @@ -15,16 +15,18 @@ * import for `ngExpressEngine`. */ -import 'zone.js/dist/zone-node'; +import 'zone.js/node'; import 'reflect-metadata'; import 'rxjs'; +import axios from 'axios'; 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 * as expressStaticGzip from 'express-static-gzip'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -37,14 +39,14 @@ import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasValue, hasNoValue } from './src/app/shared/empty.util'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; import { 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 { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; /* @@ -66,6 +68,8 @@ extendEnvironmentWithAppConfig(environment, appConfig); // The Express app is exported so that it can be used by serverless Functions. export function app() { + const router = express.Router(); + /* * Create a new express application */ @@ -74,11 +78,15 @@ export function app() { /* * If production mode is enabled in the environment file: * - Enable Angular's production mode - * - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression) + * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression) */ if (environment.production) { enableProdMode(); - server.use(compression()); + server.use(compression({ + // only compress responses we've marked as SSR + // otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin + filter: (_, res) => res.locals.ssr, + })); } /* @@ -133,7 +141,11 @@ export function app() { /** * Proxy the sitemaps */ - server.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); + router.use('/sitemap**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}/sitemaps`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); /** * Checks if the rateLimiter property is present @@ -150,15 +162,28 @@ export function app() { /* * Serve static resources (images, i18n messages, …) + * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) */ - server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); + router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { + index: false, + enableBrotli: true, + orderPreference: ['br', 'gzip'], + })); + /* * Fallthrough to the IIIF viewer (must be included in the build). */ - server.use('/iiif', express.static(IIIF_VIEWER, {index:false})); + router.use('/iiif', express.static(IIIF_VIEWER, { index: false })); + + /** + * Checking server status + */ + server.get('/app/health', healthCheck); // Register the ngApp callback function to handle incoming requests - server.get('*', ngApp); + router.get('*', ngApp); + + server.use(environment.ui.nameSpace, router); return server; } @@ -180,6 +205,7 @@ function ngApp(req, res) { providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }, (err, data) => { if (hasNoValue(err) && hasValue(data)) { + res.locals.ssr = true; // mark response as SSR res.send(data); } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { // When this error occurs we can't fall back to CSR because the response has already been @@ -191,13 +217,25 @@ function ngApp(req, res) { if (hasValue(err)) { console.warn('Error details : ', err); } - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } }); } else { // If preboot is disabled, just serve the client console.log('Universal off, serving for direct CSR'); - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } } @@ -287,6 +325,21 @@ function start() { } } +/* + * The callback function to serve health check requests + */ +function healthCheck(req, res) { + const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; + axios.get(baseUrl) + .then((response) => { + res.status(response.status).send(response.data); + }) + .catch((error) => { + res.status(error.response.status).send({ + error: error.message + }); + }); +} // Webpack will replace 'require' with '__webpack_require__' // '__non_webpack_require__' is a proxy to Node 'require' // The below code is to ensure that the server is run only when not requiring the bundle. diff --git a/src/app/access-control/epeople-registry/epeople-registry.actions.ts b/src/app/access-control/epeople-registry/epeople-registry.actions.ts index b8b1044362..a07ea37df2 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.actions.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { type } from '../../shared/ngrx/type'; @@ -16,7 +17,6 @@ export const EPeopleRegistryActionTypes = { CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_EDIT_EPERSON'), }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit an EPerson in the EPeople registry */ @@ -37,7 +37,6 @@ export class EPeopleRegistryCancelEPersonAction implements Action { type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index 7ef02a76cf..2d87f21d26 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -45,7 +45,7 @@ - + { let component: 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 41ae67423c..e9cc48aee3 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 @@ -36,12 +36,12 @@ - +
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
- + { let component: GroupFormComponent; @@ -87,6 +88,9 @@ describe('GroupFormComponent', () => { patch(group: Group, operations: Operation[]) { return null; }, + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return createSuccessfulRemoteDataObject$({}); + }, cancelEditGroup(): void { this.activeGroup = null; }, @@ -348,4 +352,46 @@ describe('GroupFormComponent', () => { }); }); + describe('delete', () => { + let deleteButton; + + beforeEach(() => { + component.initialisePage(); + + component.canEdit$ = observableOf(true); + component.groupBeingEdited = { + permanent: false + } as Group; + + fixture.detectChanges(); + deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement; + + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' })); + }); + + describe('if confirmed via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .confirm').click(); + })); + + it('should call GroupDataService.delete', () => { + expect(groupsDataServiceStub.delete).toHaveBeenCalledWith('active-group'); + }); + }); + + describe('if canceled via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .cancel').click(); + })); + + it('should not call GroupDataService.delete', () => { + expect(groupsDataServiceStub.delete).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 826b7dbe69..b0178f1294 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -426,7 +426,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { .subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name })); - this.reset(); + this.onCancel(); } else { this.notificationsService.error( this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }), @@ -439,16 +439,6 @@ export class GroupFormComponent implements OnInit, OnDestroy { }); } - /** - * This method will ensure that the page gets reset and that the cache is cleared - */ - reset() { - this.groupDataService.getBrowseEndpoint().pipe(take(1)).subscribe((href: string) => { - this.requestService.removeByHrefSubstring(href); - }); - this.onCancel(); - } - /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/access-control/group-registry/group-registry.actions.ts b/src/app/access-control/group-registry/group-registry.actions.ts index bc1c0b97a6..8144bd0599 100644 --- a/src/app/access-control/group-registry/group-registry.actions.ts +++ b/src/app/access-control/group-registry/group-registry.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { Group } from '../../core/eperson/models/group.model'; import { type } from '../../shared/ngrx/type'; @@ -16,7 +17,6 @@ export const GroupRegistryActionTypes = { CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'), }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit a Group in the Group registry */ @@ -37,7 +37,6 @@ export class GroupRegistryCancelGroupAction implements Action { type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index e791b7f2a0..ebbd223599 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -33,7 +33,7 @@
- + diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts index 0b30a551fd..99586ee5f0 100644 --- a/src/app/access-control/group-registry/groups-registry.component.spec.ts +++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts @@ -31,6 +31,7 @@ import { RouterMock } from '../../shared/mocks/router.mock'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { NoContent } from '../../core/shared/NoContent.model'; describe('GroupRegistryComponent', () => { let component: GroupsRegistryComponent; @@ -145,7 +146,10 @@ describe('GroupRegistryComponent', () => { totalPages: 1, currentPage: 1 }), [result])); - } + }, + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return createSuccessfulRemoteDataObject$({}); + }, }; dsoDataServiceStub = { findByHref(href: string): Observable> { @@ -301,4 +305,29 @@ describe('GroupRegistryComponent', () => { }); }); }); + + describe('delete', () => { + let deleteButton; + + beforeEach(fakeAsync(() => { + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + + setIsAuthorized(true, true); + + // force rerender after setup changes + component.search({ query: '' }); + tick(); + fixture.detectChanges(); + + // only mockGroup[0] is deletable, so we should only get one button + deleteButton = fixture.debugElement.query(By.css('.btn-delete')).nativeElement; + })); + + it('should call GroupDataService.delete', () => { + deleteButton.click(); + fixture.detectChanges(); + + expect(groupsDataServiceStub.delete).toHaveBeenCalledWith(mockGroups[0].id); + }); + }); }); diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index da861518da..1770762a34 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -9,7 +9,7 @@ import { of as observableOf, Subscription } from 'rxjs'; -import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -199,7 +199,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id]; this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name })); - this.reset(); } else { this.notificationsService.error( this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }), @@ -209,17 +208,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } } - /** - * This method will set everything to stale, which will cause the lists on this page to update. - */ - reset() { - this.groupService.getBrowseEndpoint().pipe( - take(1) - ).subscribe((href: string) => { - this.requestService.setStaleByHrefSubstring(href); - }); - } - /** * Get the members (epersons embedded value of a group) * @param group diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html index 42a04b0de6..24901cc11d 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html @@ -1,6 +1,17 @@

{{'admin.metadata-import.page.help' | translate}}

+
+
+ + +
+ + {{'admin.metadata-import.page.validateOnly.hint' | translate}} + +
- - +
+ + +
diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts index d663481b8c..814757ec71 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts @@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => { comp.setFile(fileMock); }); - describe('if proceed button is pressed', () => { + describe('if proceed button is pressed without validate only', () => { beforeEach(fakeAsync(() => { + comp.validateOnly = false; const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; proceed.click(); fixture.detectChanges(); @@ -107,6 +108,28 @@ describe('MetadataImportPageComponent', () => { }); }); + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + comp.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with -f fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + describe('if proceed is pressed; but script invoke fails', () => { beforeEach(fakeAsync(() => { jasmine.getEnv().allowRespy(true); diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts index 3bdcca3084..deb16c0d73 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts @@ -30,6 +30,11 @@ export class MetadataImportPageComponent { */ fileObject: File; + /** + * The validate only flag + */ + validateOnly = true; + public constructor(private location: Location, protected translate: TranslateService, protected notificationsService: NotificationsService, @@ -62,6 +67,9 @@ export class MetadataImportPageComponent { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), ]; + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( getFirstCompletedRemoteData(), diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts index 84917905d3..c5bebb292b 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-format.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; @@ -17,7 +18,6 @@ export const BitstreamFormatsRegistryActionTypes = { DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT') }; -/* tslint:disable:max-classes-per-file */ /** * Used to select a single bitstream format in the bitstream format registry */ @@ -51,7 +51,6 @@ export class BitstreamFormatsRegistryDeselectAllAction implements Action { type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 8cfba1d37b..7d3a726eec 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -25,9 +25,9 @@ import { import { createPaginatedList } from '../../../shared/testing/utils.test'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('BitstreamFormatsComponent', () => { let comp: BitstreamFormatsComponent; diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index cbbcbe07a4..89d8ac29f3 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -5,7 +5,6 @@ import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; -import { FindListOptions } from '../../../core/data/request.models'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -13,6 +12,7 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; /** * This component renders a list of bitstream formats diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts index 9737928a13..3fc872ca43 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; @@ -26,7 +27,6 @@ export const MetadataRegistryActionTypes = { DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD') }; -/* tslint:disable:max-classes-per-file */ /** * Used to edit a metadata schema in the metadata registry */ @@ -133,7 +133,6 @@ export class MetadataRegistryDeselectAllFieldAction implements Action { type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD; } -/* tslint:enable:max-classes-per-file */ /** * Export a type alias of all actions in this action group diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index 0253725cb9..74bfc5f0a4 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -21,8 +21,8 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.u import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('MetadataRegistryComponent', () => { let comp: MetadataRegistryComponent; @@ -52,7 +52,7 @@ describe('MetadataRegistryComponent', () => { } ]; const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getMetadataSchemas: () => mockSchemas, getActiveMetadataSchema: () => observableOf(undefined), @@ -66,7 +66,7 @@ describe('MetadataRegistryComponent', () => { }, clearMetadataSchemaRequests: () => observableOf(undefined) }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ paginationService = new PaginationServiceStub(); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts index 8574c4678b..857034604e 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -128,7 +128,6 @@ export class MetadataRegistryComponent { * Delete all the selected metadata schemas */ deleteSchemas() { - this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( (schemas) => { const tasks$ = []; @@ -148,7 +147,6 @@ export class MetadataRegistryComponent { } this.registryService.deselectAllMetadataSchema(); this.registryService.cancelEditMetadataSchema(); - this.forceUpdateSchemas(); }); } ); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index f486c3c132..8d416c2df8 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -17,7 +17,7 @@ describe('MetadataSchemaFormComponent', () => { let fixture: ComponentFixture; let registryService: RegistryService; - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), @@ -33,7 +33,7 @@ describe('MetadataSchemaFormComponent', () => { }; } }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 1bd25be113..e13180d633 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -24,7 +24,7 @@ describe('MetadataFieldFormComponent', () => { prefix: 'fake' }); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getActiveMetadataField: () => observableOf(undefined), createMetadataField: (field: MetadataField) => observableOf(field), @@ -43,7 +43,7 @@ describe('MetadataFieldFormComponent', () => { }; } }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 6eb3c5b1a4..c4116dc9e0 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -25,9 +25,9 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.u import { VarDirective } from '../../../shared/utils/var.directive'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../../core/data/request.models'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('MetadataSchemaComponent', () => { let comp: MetadataSchemaComponent; @@ -106,7 +106,7 @@ describe('MetadataSchemaComponent', () => { } ]; const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const registryServiceStub = { getMetadataSchemas: () => mockSchemas, getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))), @@ -122,7 +122,7 @@ describe('MetadataSchemaComponent', () => { }, clearMetadataFieldRequests: () => observableOf(undefined) }; - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ const schemaNameParam = 'mock'; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { params: observableOf({ diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts index 8a2086d5e2..d0827e6e4d 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -174,15 +174,12 @@ export class MetadataSchemaComponent implements OnInit { const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); if (successResponses.length > 0) { this.showNotification(true, successResponses.length); - this.registryService.clearMetadataFieldRequests(); - } if (failedResponses.length > 0) { this.showNotification(false, failedResponses.length); } this.registryService.deselectAllMetadataField(); this.registryService.cancelEditMetadataField(); - this.forceUpdateFields(); }); } ); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..a6ea7e4946 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,8 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service'; +import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -31,6 +33,12 @@ describe('ItemAdminSearchResultGridElementComponent', () => { } }; + const mockAccessStatusDataService = { + findAccessStatusFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new AccessStatusObject()); + } + }; + const mockThemeService = getMockThemeService(); function init() { @@ -55,6 +63,7 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html index fbbbbb255c..ba4ab15363 100644 --- a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html +++ b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html @@ -1,28 +1,30 @@ - - {{"admin.search.item.move" | translate}} - + diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index 50f9f8a79e..47f693cb99 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -1,10 +1,10 @@ import { Component, Inject, Injector, OnInit } from '@angular/core'; import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component'; -import { MenuID } from '../../../shared/menu/initial-menus-state'; import { MenuService } from '../../../shared/menu/menu.service'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; -import { MenuSection } from '../../../shared/menu/menu.reducer'; +import { MenuSection } from '../../../shared/menu/menu-section.model'; +import { MenuID } from '../../../shared/menu/menu-id.model'; import { isNotEmpty } from '../../../shared/empty.util'; import { Router } from '@angular/router'; @@ -12,7 +12,7 @@ import { Router } from '@angular/router'; * Represents a non-expandable section in the admin sidebar */ @Component({ - /* tslint:disable:component-selector */ + /* eslint-disable @angular-eslint/component-selector */ selector: 'li[ds-admin-sidebar-section]', templateUrl: './admin-sidebar-section.component.html', styleUrls: ['./admin-sidebar-section.component.scss'], 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 65026c1504..b7fa396e80 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -20,6 +20,8 @@ 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'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; describe('AdminSidebarComponent', () => { let comp: AdminSidebarComponent; @@ -60,6 +62,7 @@ describe('AdminSidebarComponent', () => { declarations: [AdminSidebarComponent], providers: [ Injector, + { provide: ThemeService, useValue: getMockThemeService() }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: AuthService, useClass: AuthServiceStub }, @@ -182,150 +185,4 @@ describe('AdminSidebarComponent', () => { expect(menuService.collapseMenuPreview).toHaveBeenCalled(); })); }); - - describe('menu', () => { - beforeEach(() => { - spyOn(menuService, 'addSection'); - }); - - describe('for regular user', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => { - return observableOf(false); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should not show site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: false, - })); - }); - - it('should not show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: false, - })); - - }); - - it('should not show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: false, - })); - }); - - it('should not show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: false, - })); - - }); - }); - - describe('for site admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.AdministratorOf); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should contain site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - }); - }); - - describe('for community admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCommunityAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: true, - })); - }); - }); - - describe('for collection admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCollectionAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: true, - })); - }); - }); - - describe('for group admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanManageGroups); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - 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 c81b2e6e93..6029387bfc 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,27 +1,15 @@ import { Component, HostListener, Injector, OnInit } from '@angular/core'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { combineLatest, combineLatest as observableCombineLatest, Observable, BehaviorSubject } from 'rxjs'; -import { debounceTime, first, map, take, distinctUntilChanged, withLatestFrom } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; -import { ScriptDataService } from '../../core/data/processes/script-data.service'; import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; -import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; -import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; -import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; -import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; -import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; -import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; -import { ExportMetadataSelectorComponent } from '../../shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; -import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; -import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; -import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; -import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; import { MenuComponent } from '../../shared/menu/menu.component'; 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'; +import { MenuID } from '../../shared/menu/menu-id.model'; +import { ActivatedRoute } from '@angular/router'; +import { ThemeService } from '../../shared/theme-support/theme.service'; /** * Component representing the admin sidebar @@ -63,16 +51,16 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { inFocus$: BehaviorSubject; - constructor(protected menuService: MenuService, + constructor( + protected menuService: MenuService, protected injector: Injector, private variableService: CSSVariableService, private authService: AuthService, - private modalService: NgbModal, public authorizationService: AuthorizationDataService, - private scriptDataService: ScriptDataService, - public route: ActivatedRoute + public route: ActivatedRoute, + protected themeService: ThemeService ) { - super(menuService, injector, authorizationService, route); + super(menuService, injector, authorizationService, route, themeService); this.inFocus$ = new BehaviorSubject(false); } @@ -80,7 +68,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { * Set and calculate all initial values of the instance variables */ ngOnInit(): void { - this.createMenu(); super.ngOnInit(); this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth'); this.authService.isAuthenticated() @@ -115,501 +102,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { }); } - /** - * Initialize all menu sections and items for this menu - */ - createMenu() { - this.createMainMenuSections(); - this.createSiteAdministratorMenuSections(); - this.createExportMenuSections(); - this.createImportMenuSections(); - this.createAccessControlMenuSections(); - } - - /** - * Initialize the main menu sections. - * edit_community / edit_collection is only included if the current user is a Community or Collection admin - */ - createMainMenuSections() { - combineLatest([ - this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), - this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), - this.authorizationService.isAuthorized(FeatureID.AdministratorOf) - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { - const menuList = [ - /* News */ - { - id: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.new' - } as TextMenuItemModel, - icon: 'plus', - index: 0 - }, - { - id: 'new_community', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_community', - function: () => { - this.modalService.open(CreateCommunityParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_collection', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_collection', - function: () => { - this.modalService.open(CreateCollectionParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_item', - parentID: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_item', - function: () => { - this.modalService.open(CreateItemParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_process', - parentID: 'new', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.new_process', - link: '/processes/new' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'new_item_version', - // parentID: 'new', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.new_item_version', - // link: '' - // } as LinkMenuItemModel, - // }, - - /* Edit */ - { - id: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - } as TextMenuItemModel, - icon: 'pencil-alt', - index: 1 - }, - { - id: 'edit_community', - parentID: 'edit', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_community', - function: () => { - this.modalService.open(EditCommunitySelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_collection', - parentID: 'edit', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_collection', - function: () => { - this.modalService.open(EditCollectionSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_item', - parentID: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_item', - function: () => { - this.modalService.open(EditItemSelectorComponent); - } - } as OnClickMenuItemModel, - }, - - /* Statistics */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'statistics_task', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.statistics_task', - // link: '' - // } as LinkMenuItemModel, - // icon: 'chart-bar', - // index: 8 - // }, - - /* Control Panel */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'control_panel', - // active: false, - // visible: isSiteAdmin, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.control_panel', - // link: '' - // } as LinkMenuItemModel, - // icon: 'cogs', - // index: 9 - // }, - - /* Processes */ - { - id: 'processes', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes' - } as LinkMenuItemModel, - icon: 'terminal', - index: 10 - }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the export scripts exist and the current user is allowed to execute them - */ - createExportMenuSections() { - const menuList = [ - /* Export */ - { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'file-export', - index: 3, - shouldPersistOnRouteChange: true - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_community', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_community', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_collection', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_collection', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_item', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_item', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); - - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) - ).pipe( - // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed; otherwise even in production mode, the metadata export button is only available after a refresh (and not in dev mode) - // filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), - take(1) - ).subscribe(() => { - this.menuService.addSection(this.menuID, { - id: 'export_metadata', - parentID: 'export', - active: true, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_metadata', - function: () => { - this.modalService.open(ExportMetadataSelectorComponent); - } - } as OnClickMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the import scripts exist and the current user is allowed to execute them - */ - createImportMenuSections() { - const menuList = [ - /* Import */ - { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'file-import', - index: 2 - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'import_batch', - // parentID: 'import', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.import_batch', - // link: '' - // } as LinkMenuItemModel, - // } - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) - ).pipe( - // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed - // filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), - take(1) - ).subscribe(() => { - this.menuService.addSection(this.menuID, { - id: 'import_metadata', - parentID: 'import', - active: true, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '/admin/metadata-import' - } as LinkMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator - */ - createSiteAdministratorMenuSections() { - this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => { - const menuList = [ - /* Admin Search */ - { - id: 'admin_search', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.admin_search', - link: '/admin/search' - } as LinkMenuItemModel, - icon: 'search', - index: 5 - }, - /* Registries */ - { - id: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries' - } as TextMenuItemModel, - icon: 'list', - index: 6 - }, - { - id: 'registries_metadata', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_metadata', - link: 'admin/registries/metadata' - } as LinkMenuItemModel, - }, - { - id: 'registries_format', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_format', - link: 'admin/registries/bitstream-formats' - } as LinkMenuItemModel, - }, - - /* Curation tasks */ - { - id: 'curation_tasks', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.curation_task', - link: 'admin/curation-tasks' - } as LinkMenuItemModel, - icon: 'filter', - index: 7 - }, - - /* Workflow */ - { - id: 'workflow', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.workflow', - link: '/admin/workflow' - } as LinkMenuItemModel, - icon: 'user-check', - index: 11 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user can manage access control groups - */ - createAccessControlMenuSections() { - observableCombineLatest( - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanManageGroups) - ).subscribe(([isSiteAdmin, canManageGroups]) => { - const menuList = [ - /* Access Control */ - { - id: 'access_control_people', - parentID: 'access_control', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_people', - link: '/access-control/epeople' - } as LinkMenuItemModel, - }, - { - id: 'access_control_groups', - parentID: 'access_control', - active: false, - visible: canManageGroups, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_groups', - link: '/access-control/groups' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'access_control_authorizations', - // parentID: 'access_control', - // active: false, - // visible: authorized, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.access_control_authorizations', - // link: '' - // } as LinkMenuItemModel, - // }, - { - id: 'access_control', - active: false, - visible: canManageGroups || isSiteAdmin, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.access_control' - } as TextMenuItemModel, - icon: 'key', - index: 4 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true, - }))); - }); - } - @HostListener('focusin') public handleFocusIn() { this.inFocus$.next(true); diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index aaa6a85c51..7cd20b15d2 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -4,18 +4,18 @@ import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sid import { slide } from '../../../shared/animations/slide'; import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; import { bgColor } from '../../../shared/animations/bgColor'; -import { MenuID } from '../../../shared/menu/initial-menus-state'; import { MenuService } from '../../../shared/menu/menu.service'; import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; +import { MenuID } from '../../../shared/menu/menu-id.model'; import { Router } from '@angular/router'; /** * Represents a expandable section in the sidebar */ @Component({ - /* tslint:disable:component-selector */ + /* eslint-disable @angular-eslint/component-selector */ selector: 'li[ds-expandable-admin-sidebar-section]', templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'], diff --git a/src/app/admin/admin-sidebar/themed-admin-sidebar.component.ts b/src/app/admin/admin-sidebar/themed-admin-sidebar.component.ts new file mode 100644 index 0000000000..f2f35725e8 --- /dev/null +++ b/src/app/admin/admin-sidebar/themed-admin-sidebar.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { AdminSidebarComponent } from './admin-sidebar.component'; + +/** + * Themed wrapper for AdminSidebarComponent + */ +@Component({ + selector: 'ds-themed-admin-sidebar', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedAdminSidebarComponent extends ThemedComponent { + protected getComponentName(): string { + return 'AdminSidebarComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/admin/admin-sidebar/admin-sidebar.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./admin-sidebar.component'); + } +} diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html b/src/app/admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html index 4d7266514c..7f19ef3c2e 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html @@ -1,7 +1,8 @@ - + diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 57767b6f3e..e9a6376884 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -70,6 +70,12 @@ export function getWorkflowItemModuleRoute() { return `/${WORKFLOW_ITEM_MODULE_PATH}`; } +export const WORKSPACE_ITEM_MODULE_PATH = 'workspaceitems'; + +export function getWorkspaceItemModuleRoute() { + return `/${WORKSPACE_ITEM_MODULE_PATH}`; +} + export function getDSORoute(dso: DSpaceObject): string { if (hasValue(dso)) { switch ((dso as any).type) { @@ -101,6 +107,8 @@ export function getPageInternalServerErrorRoute() { return `/${INTERNAL_SERVER_ERROR}`; } +export const ERROR_PAGE = 'error'; + export const INFO_MODULE_PATH = 'info'; export function getInfoModulePath() { return `/${INFO_MODULE_PATH}`; @@ -116,3 +124,5 @@ export const REQUEST_COPY_MODULE_PATH = 'request-a-copy'; export function getRequestCopyModulePath() { return `/${REQUEST_COPY_MODULE_PATH}`; } + +export const HEALTH_PAGE_PATH = 'health'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 88f7791b1b..d426b041ce 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,15 +1,19 @@ import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { RouterModule, NoPreloading } from '@angular/router'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; -import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + SiteAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { ACCESS_CONTROL_MODULE_PATH, ADMIN_MODULE_PATH, BITSTREAM_MODULE_PATH, + ERROR_PAGE, FORBIDDEN_PATH, FORGOT_PASSWORD_PATH, + HEALTH_PAGE_PATH, INFO_MODULE_PATH, INTERNAL_SERVER_ERROR, LEGACY_BITSTREAM_MODULE_PATH, @@ -27,18 +31,26 @@ import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end- import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; -import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; +import { + GroupAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { + ThemedPageInternalServerErrorComponent +} from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { MenuResolver } from './menu.resolver'; +import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; @NgModule({ imports: [ RouterModule.forRoot([ { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent }, + { path: ERROR_PAGE , component: ThemedPageErrorComponent }, { path: '', canActivate: [AuthBlockingGuard], canActivateChild: [ServerCheckGuard], + resolve: [MenuResolver], children: [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { @@ -208,6 +220,11 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard'; loadChildren: () => import('./statistics-page/statistics-page-routing.module') .then((m) => m.StatisticsPageRoutingModule) }, + { + path: HEALTH_PAGE_PATH, + loadChildren: () => import('./health-page/health-page.module') + .then((m) => m.HealthPageModule) + }, { path: ACCESS_CONTROL_MODULE_PATH, loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), @@ -217,6 +234,12 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard'; ] } ], { + // enableTracing: true, + useHash: false, + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + initialNavigation: 'enabledBlocking', + preloadingStrategy: NoPreloading, onSameUrlNavigation: 'reload', }) ], diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index a892e34a5a..8bec7edc80 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -4,7 +4,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule, DOCUMENT } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; // Load the implementations that should be tested import { AppComponent } from './app.component'; @@ -32,7 +32,6 @@ import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; -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'; @@ -46,16 +45,16 @@ const initialState = { core: { auth: { loading: false } } }; +export function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') + }); +} + describe('App component', () => { let breadcrumbsServiceSpy; - function getMockLocaleService(): LocaleService { - return jasmine.createSpyObj('LocaleService', { - setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') - }); - } - const getDefaultTestBedConf = () => { breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']); @@ -130,66 +129,4 @@ describe('App component', () => { }); }); - - describe('the constructor', () => { - it('should call breadcrumbsService.listenForRouteChanges', () => { - expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); - }); - }); - - describe('when GoogleAnalyticsService is provided', () => { - let googleAnalyticsSpy; - - beforeEach(() => { - // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset - TestBed.resetTestingModule(); - TestBed.configureTestingModule(getDefaultTestBedConf()); - googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [ - 'addTrackingIdToPage', - ]); - TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy}); - fixture = TestBed.createComponent(AppComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create component', () => { - expect(comp).toBeTruthy(); - }); - - describe('the constructor', () => { - it('should call googleAnalyticsService.addTrackingIdToPage()', () => { - expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('when ThemeService returns a custom theme', () => { - let document; - let headSpy; - - beforeEach(() => { - // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset - TestBed.resetTestingModule(); - TestBed.configureTestingModule(getDefaultTestBedConf()); - TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); - document = TestBed.inject(DOCUMENT); - headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']); - headSpy.getElementsByClassName.and.returnValue([]); - spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); - fixture = TestBed.createComponent(AppComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should append a link element with the correct attributes to the head element', () => { - const link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('type', 'text/css'); - link.setAttribute('class', 'theme-css'); - link.setAttribute('href', '/custom-theme.css'); - - expect(headSpy.appendChild).toHaveBeenCalledWith(link); - }); - }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 669411d9aa..7aa1043da1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, @@ -7,47 +7,31 @@ import { HostListener, Inject, OnInit, - Optional, PLATFORM_ID, } from '@angular/core'; import { - ActivatedRouteSnapshot, NavigationCancel, NavigationEnd, - NavigationStart, ResolveEnd, + NavigationStart, Router, } from '@angular/router'; -import { isEqual } from 'lodash'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; - -import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticationBlocking } from './core/auth/selectors'; 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 { 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 { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util'; -import { KlaroService } from './shared/cookies/klaro.service'; -import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { ThemeService } from './shared/theme-support/theme.service'; -import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; -import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { getDefaultThemeConfig } from '../config/config.util'; -import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +import { distinctNext } from './core/shared/distinct-next'; +import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-app', @@ -56,11 +40,6 @@ import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit, AfterViewInit { - sidebarVisible: Observable; - slideSidebarOver: Observable; - collapsedSidebarWidth: Observable; - totalSidebarWidth: Observable; - theme: Observable = of({} as any); notificationOptions; models; @@ -72,14 +51,12 @@ export class AppComponent implements OnInit, AfterViewInit { /** * Whether or not the app is in the process of rerouting */ - isRouteLoading$: BehaviorSubject = new BehaviorSubject(true); + isRouteLoading$: BehaviorSubject = new BehaviorSubject(false); /** * Whether or not the theme is in the process of being swapped */ - isThemeLoading$: BehaviorSubject = new BehaviorSubject(false); - - isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(false); + isThemeLoading$: Observable; /** * Whether or not the idle modal is is currently open @@ -90,93 +67,45 @@ 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, - private metadata: MetadataService, - private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, - private angulartics2DSpace: Angulartics2DSpace, private authService: AuthService, private router: Router, private cssService: CSSVariableService, - private menuService: MenuService, - private windowService: HostWindowService, - private localeService: LocaleService, - private breadcrumbsService: BreadcrumbsService, private modalService: NgbModal, - @Optional() private cookiesService: KlaroService, - @Optional() private googleAnalyticsService: GoogleAnalyticsService, + private modalConfig: NgbModalConfig, ) { - - 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; - this.themeService.getThemeName$().subscribe((themeName: string) => { - if (isPlatformBrowser(this.platformId)) { - // the theme css will never download server side, so this should only happen on the browser - this.isThemeCSSLoading$.next(true); - } - if (hasValue(themeName)) { - this.loadGlobalThemeConfig(themeName); - } else { - const defaultThemeConfig = getDefaultThemeConfig(); - if (hasValue(defaultThemeConfig)) { - this.loadGlobalThemeConfig(defaultThemeConfig.name); - } else { - this.loadGlobalThemeConfig(BASE_THEME_NAME); - } - } - }); - if (isPlatformBrowser(this.platformId)) { - this.authService.trackTokenExpiration(); this.trackIdleModal(); } - // Load all the languages that are defined as active from the config file - translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); + this.isThemeLoading$ = this.themeService.isThemeLoading$; - // Load the default language from the config file - // translate.setDefaultLang(environment.defaultLanguage); - - // set the current language code - this.localeService.setCurrentLanguageCode(); - - // analytics - if (hasValue(googleAnalyticsService)) { - googleAnalyticsService.addTrackingIdToPage(); - } - angulartics2DSpace.startTracking(); - - metadata.listenForRouteChange(); - breadcrumbsService.listenForRouteChanges(); - - if (environment.debug) { - console.info(environment); - } this.storeCSSVariables(); } ngOnInit() { - this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( + /** Implement behavior for interface {@link ModalBeforeDismiss} */ + this.modalConfig.beforeDismiss = async function () { + if (typeof this?.componentInstance?.beforeDismiss === 'function') { + return this.componentInstance.beforeDismiss(); + } + + // fall back to default behavior + return true; + }; + + this.isAuthBlocking$ = this.store.pipe( + select(isAuthenticationBlocking), distinctUntilChanged() ); - this.isAuthBlocking$ - .pipe( - filter((isBlocking: boolean) => isBlocking === false), - take(1) - ).subscribe(() => this.initializeKlaro()); - const env: string = environment.production ? 'Production' : 'Development'; - const color: string = environment.production ? 'red' : 'green'; - console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); } @@ -196,34 +125,14 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - let resolveEndFound = false; this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { - resolveEndFound = false; - this.isRouteLoading$.next(true); - this.isThemeLoading$.next(true); - } else if (event instanceof ResolveEnd) { - resolveEndFound = true; - const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; - this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe( - switchMap((changed) => { - if (changed) { - return this.isThemeCSSLoading$; - } else { - return [false]; - } - }) - ).subscribe((changed) => { - this.isThemeLoading$.next(changed); - }); + distinctNext(this.isRouteLoading$, true); } else if ( event instanceof NavigationEnd || event instanceof NavigationCancel ) { - if (!resolveEndFound) { - this.isThemeLoading$.next(false); - } - this.isRouteLoading$.next(false); + distinctNext(this.isRouteLoading$, false); } }); } @@ -239,125 +148,6 @@ export class AppComponent implements OnInit, AfterViewInit { ); } - private initializeKlaro() { - if (hasValue(this.cookiesService)) { - this.cookiesService.initialize(); - } - } - - private loadGlobalThemeConfig(themeName: string): void { - this.setThemeCss(themeName); - this.setHeadTags(themeName); - } - - /** - * Update the theme css file in - * - * @param themeName The name of the new theme - * @private - */ - 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(head.getElementsByClassName('theme-css')); - const link = this.document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('type', 'text/css'); - link.setAttribute('class', 'theme-css'); - link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`); - // wait for the new css to download before removing the old one to prevent a - // flash of unstyled content - link.onload = () => { - if (isNotEmpty(currentThemeLinks)) { - currentThemeLinks.forEach((currentThemeLink: any) => { - if (hasValue(currentThemeLink)) { - currentThemeLink.remove(); - } - }); - } - // the fact that this callback is used, proves we're on the browser. - this.isThemeCSSLoading$.next(false); - }; - 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(); @@ -376,4 +166,5 @@ export class AppComponent implements OnInit, AfterViewInit { } }); } + } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 67bccd9105..392969d041 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,71 +1,46 @@ -import { APP_BASE_HREF, CommonModule } from '@angular/common'; +import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { 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'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; -import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; -import { StoreDevtoolsModule } from '@ngrx/store-devtools'; -import { - DYNAMIC_ERROR_MESSAGES_MATCHER, - DYNAMIC_MATCHER_PROVIDERS, - DynamicErrorMessagesMatcher -} from '@ng-dynamic-forms/core'; +import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; +import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; - -import { AdminSidebarSectionComponent } from './admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; -import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component'; -import { ExpandableAdminSidebarSectionComponent } from './admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appReducers, AppState, storeModuleConfig } from './app.reducer'; -import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; -import { FooterComponent } from './footer/footer.component'; -import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component'; -import { HeaderComponent } from './header/header.component'; import { NavbarModule } from './navbar/navbar.module'; -import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; -import { NotificationComponent } from './shared/notifications/notification/notification.component'; -import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { SharedModule } from './shared/shared.module'; -import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { environment } from '../environments/environment'; -import { ForbiddenComponent } from './forbidden/forbidden.component'; import { AuthInterceptor } from './core/auth/auth.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { LogInterceptor } from './core/log/log.interceptor'; -import { RootComponent } from './root/root.component'; -import { ThemedRootComponent } from './root/themed-root.component'; -import { ThemedEntryComponentModule } from '../themes/themed-entry-component.module'; -import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; -import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; -import { ThemedHeaderComponent } from './header/themed-header.component'; -import { ThemedFooterComponent } from './footer/themed-footer.component'; -import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component'; -import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component'; -import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; -import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component'; - +import { EagerThemesModule } from '../themes/eager-themes.module'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; +import { NgxMaskModule } from 'ngx-mask'; +import { StoreDevModules } from '../config/store/devtools'; +import { RootModule } from './root.module'; export function getConfig() { return environment; } -export function getBase(appConfig: AppConfig) { - return appConfig.ui.nameSpace; -} +const getBaseHref = (document: Document, appConfig: AppConfig): string => { + const baseTag = document.querySelector('head > base'); + baseTag.setAttribute('href', `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`); + return baseTag.getAttribute('href'); +}; export function getMetaReducers(appConfig: AppConfig): MetaReducer[] { return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; @@ -89,28 +64,20 @@ const IMPORTS = [ ScrollToModule.forRoot(), NgbModule, TranslateModule.forRoot(), + NgxMaskModule.forRoot(), EffectsModule.forRoot(appEffects), StoreModule.forRoot(appReducers, storeModuleConfig), StoreRouterConnectingModule.forRoot(), - ThemedEntryComponentModule.withEntryComponents(), + StoreDevModules, + EagerThemesModule, + RootModule, ]; -IMPORTS.push( - StoreDevtoolsModule.instrument({ - maxAge: 1000, - logOnly: environment.production, - }) -); - const PROVIDERS = [ - { - provide: APP_CONFIG, - useFactory: getConfig - }, { provide: APP_BASE_HREF, - useFactory: getBase, - deps: [APP_CONFIG] + useFactory: getBaseHref, + deps: [DOCUMENT, APP_CONFIG] }, { provide: USER_PROVIDED_META_REDUCERS, @@ -122,15 +89,6 @@ const PROVIDERS = [ useClass: DSpaceRouterStateSerializer }, ClientCookieService, - // Check the authentication token when the app initializes - { - provide: APP_INITIALIZER, - useFactory: (store: Store,) => { - return () => store.dispatch(new CheckAuthenticationTokenAction()); - }, - deps: [Store], - multi: true - }, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -164,28 +122,6 @@ const PROVIDERS = [ const DECLARATIONS = [ AppComponent, - RootComponent, - ThemedRootComponent, - HeaderComponent, - ThemedHeaderComponent, - HeaderNavbarWrapperComponent, - ThemedHeaderNavbarWrapperComponent, - AdminSidebarComponent, - AdminSidebarSectionComponent, - ExpandableAdminSidebarSectionComponent, - FooterComponent, - ThemedFooterComponent, - PageNotFoundComponent, - ThemedPageNotFoundComponent, - NotificationComponent, - NotificationsBoardComponent, - BreadcrumbsComponent, - ThemedBreadcrumbsComponent, - ForbiddenComponent, - ThemedForbiddenComponent, - IdleModalComponent, - ThemedPageInternalServerErrorComponent, - PageInternalServerErrorComponent ]; const EXPORTS = [ diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 5bd4f745d9..1d6e86463d 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -22,7 +22,7 @@ import { nameVariantReducer } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { formReducer, FormState } from './shared/form/form.reducer'; -import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { menusReducer} from './shared/menu/menu.reducer'; import { notificationsReducer, NotificationsState @@ -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 { MenusState } from './shared/menu/menus-state.model'; import { correlationIdReducer } from './correlation-id/correlation-id.reducer'; export interface AppState { diff --git a/src/app/bitstream-page/bitstream-page-routing.module.ts b/src/app/bitstream-page/bitstream-page-routing.module.ts index 27b9db9a05..0bdda29ddf 100644 --- a/src/app/bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/bitstream-page/bitstream-page-routing.module.ts @@ -10,6 +10,9 @@ import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/re import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component'; import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; import { LegacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver'; +import { BitstreamBreadcrumbResolver } from '../core/breadcrumbs/bitstream-breadcrumb.resolver'; +import { BitstreamBreadcrumbsService } from '../core/breadcrumbs/bitstream-breadcrumbs.service'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; const EDIT_BITSTREAM_PATH = ':id/edit'; const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; @@ -48,7 +51,8 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; path: EDIT_BITSTREAM_PATH, component: EditBitstreamPageComponent, resolve: { - bitstream: BitstreamPageResolver + bitstream: BitstreamPageResolver, + breadcrumb: BitstreamBreadcrumbResolver, }, canActivate: [AuthenticatedGuard] }, @@ -67,15 +71,17 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; { path: 'edit', resolve: { + breadcrumb: I18nBreadcrumbResolver, resourcePolicy: ResourcePolicyResolver }, component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true } + data: { breadcrumbKey: 'item.edit', title: 'resource-policies.edit.page.title', showBreadcrumbs: true } }, { path: '', resolve: { - bitstream: BitstreamPageResolver + bitstream: BitstreamPageResolver, + breadcrumb: BitstreamBreadcrumbResolver, }, component: BitstreamAuthorizationsComponent, data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true } @@ -86,6 +92,8 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; ], providers: [ BitstreamPageResolver, + BitstreamBreadcrumbResolver, + BitstreamBreadcrumbsService ] }) export class BitstreamPageRoutingModule { diff --git a/src/app/bitstream-page/bitstream-page.resolver.ts b/src/app/bitstream-page/bitstream-page.resolver.ts index fd9d5b351b..be92041dfc 100644 --- a/src/app/bitstream-page/bitstream-page.resolver.ts +++ b/src/app/bitstream-page/bitstream-page.resolver.ts @@ -7,6 +7,15 @@ import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ + export const BITSTREAM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('bundle', {}, followLink('item')), + followLink('format') +]; + /** * This class represents a resolver that requests a specific bitstream before the route is activated */ @@ -34,9 +43,6 @@ export class BitstreamPageResolver implements Resolve> { * Requesting them as embeds will limit the number of requests */ get followLinks(): FollowLinkConfig[] { - return [ - followLink('bundle', {}, followLink('item')), - followLink('format') - ]; + return BITSTREAM_PAGE_LINKS_TO_FOLLOW; } } diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index 4d3b948a58..6454198340 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -27,7 +27,7 @@ - + diff --git a/src/app/bitstream-page/legacy-bitstream-url.resolver.spec.ts b/src/app/bitstream-page/legacy-bitstream-url.resolver.spec.ts index 25e245c5b7..045582cb26 100644 --- a/src/app/bitstream-page/legacy-bitstream-url.resolver.spec.ts +++ b/src/app/bitstream-page/legacy-bitstream-url.resolver.spec.ts @@ -2,8 +2,8 @@ import { LegacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver'; import { of as observableOf, EMPTY } from 'rxjs'; import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { RemoteData } from '../core/data/remote-data'; -import { RequestEntryState } from '../core/data/request.reducer'; import { TestScheduler } from 'rxjs/testing'; +import { RequestEntryState } from '../core/data/request-entry-state.model'; describe(`LegacyBitstreamUrlResolver`, () => { let resolver: LegacyBitstreamUrlResolver; diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts index 7b0ddcb18e..15ec9d78db 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts @@ -20,9 +20,9 @@ import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../core/data/find-list-options.model'; describe('BrowseByDatePageComponent', () => { let comp: BrowseByDatePageComponent; 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 1bdbb91a8b..271828a38e 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 @@ -18,6 +18,7 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { isValidDate } from '../../shared/date.util'; @Component({ selector: 'ds-browse-by-date-page', @@ -85,10 +86,10 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { let lowerLimit = environment.browseBy.defaultLowerLimit; if (hasValue(firstItemRD.payload)) { const date = firstItemRD.payload.firstMetadataValue(metadataKeys); - if (hasValue(date)) { + if (isNotEmpty(date) && isValidDate(date)) { const dateObj = new Date(date); // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. - lowerLimit = dateObj.getUTCFullYear(); + lowerLimit = isNaN(dateObj.getUTCFullYear()) ? lowerLimit : dateObj.getUTCFullYear(); } } const options = []; diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html index 2321da0204..eb15ac9523 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html @@ -18,24 +18,29 @@ - +
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 f789389697..c685fe31a7 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 @@ -18,6 +18,8 @@ import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse- import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; +export const BBM_PAGINATION_ID = 'bbm'; + @Component({ selector: 'ds-browse-by-metadata-page', styleUrls: ['./browse-by-metadata-page.component.scss'], @@ -50,7 +52,7 @@ export class BrowseByMetadataPageComponent implements OnInit { * The pagination config used to display the values */ paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'bbm', + id: BBM_PAGINATION_ID, currentPage: 1, pageSize: 20 }); @@ -127,10 +129,10 @@ export class BrowseByMetadataPageComponent implements OnInit { return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + this.browseId = params.id || this.defaultBrowseId; this.authority = params.authority; - this.value = +params.value || params.value || ''; - this.startsWith = +params.startsWith || params.startsWith; + 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.authority); diff --git a/src/app/browse-by/browse-by-routing.module.ts b/src/app/browse-by/browse-by-routing.module.ts index 72d78f13fd..5788d3cc70 100644 --- a/src/app/browse-by/browse-by-routing.module.ts +++ b/src/app/browse-by/browse-by-routing.module.ts @@ -17,7 +17,7 @@ import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-bro component: ThemedBrowseBySwitcherComponent, canActivate: [BrowseByGuard], resolve: { breadcrumb: BrowseByI18nBreadcrumbResolver }, - data: { title: 'browse.title', breadcrumbKey: 'browse.metadata' } + data: { title: 'browse.title.page', breadcrumbKey: 'browse.metadata' } } ] }]) 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 1ebaa7face..ceb4c6a6c6 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 @@ -1,6 +1,10 @@ import { hasNoValue } from '../../shared/empty.util'; import { InjectionToken } from '@angular/core'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { + DEFAULT_THEME, + resolveTheme +} from '../../shared/object-collection/shared/listable-object/listable-object.decorator'; export enum BrowseByDataType { Title = 'title', @@ -10,7 +14,7 @@ export enum BrowseByDataType { export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata; -export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor>('getComponentByBrowseByType', { +export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType, theme) => GenericConstructor>('getComponentByBrowseByType', { providedIn: 'root', factory: () => getComponentByBrowseByType }); @@ -20,13 +24,17 @@ const map = new Map(); /** * Decorator used for rendering Browse-By pages by type * @param browseByType The type of page + * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: BrowseByDataType) { +export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { - map.set(browseByType, component); + map.set(browseByType, new Map()); + } + if (hasNoValue(map.get(browseByType).get(theme))) { + map.get(browseByType).set(theme, component); } else { - throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}"`); + throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}" and theme "${theme}"`); } }; } @@ -34,11 +42,16 @@ export function rendersBrowseBy(browseByType: BrowseByDataType) { /** * Get the component used for rendering a Browse-By page by type * @param browseByType The type of page + * @param theme the theme to match */ -export function getComponentByBrowseByType(browseByType) { - const comp = map.get(browseByType); +export function getComponentByBrowseByType(browseByType, theme) { + let themeMap = map.get(browseByType); + if (hasNoValue(themeMap)) { + themeMap = map.get(DEFAULT_BROWSE_BY_TYPE); + } + const comp = resolveTheme(themeMap, theme); if (hasNoValue(comp)) { - map.get(DEFAULT_BROWSE_BY_TYPE); + return themeMap.get(DEFAULT_THEME); } return comp; } 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 cb82ddb7c4..c2e1c9cb68 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 @@ -4,7 +4,8 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; 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'; +import { BehaviorSubject } from 'rxjs'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -44,11 +45,20 @@ describe('BrowseBySwitcherComponent', () => { data }; + let themeService: ThemeService; + let themeName: string; + beforeEach(waitForAsync(() => { + themeName = 'dspace'; + themeService = jasmine.createSpyObj('themeService', { + getThemeName: themeName, + }); + TestBed.configureTestingModule({ declarations: [BrowseBySwitcherComponent], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ThemeService, useValue: themeService }, { provide: BROWSE_BY_COMPONENT_FACTORY, useValue: jasmine.createSpy('getComponentByBrowseByType').and.returnValue(null) } ], schemas: [NO_ERRORS_SCHEMA] @@ -68,7 +78,7 @@ describe('BrowseBySwitcherComponent', () => { }); it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => { - expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType); + expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType, themeName); }); }); }); 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 cf4c1d9856..0d3a35bebf 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 @@ -5,6 +5,7 @@ import { map } from 'rxjs/operators'; import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { ThemeService } from '../../shared/theme-support/theme.service'; @Component({ selector: 'ds-browse-by-switcher', @@ -21,7 +22,8 @@ export class BrowseBySwitcherComponent implements OnInit { browseByComponent: Observable; public constructor(protected route: ActivatedRoute, - @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType) => GenericConstructor) { + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { } /** @@ -29,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit { */ ngOnInit(): void { this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType)) + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName())) ); } diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts index 584da1c45a..554b059ac5 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts @@ -20,9 +20,9 @@ import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../core/data/find-list-options.model'; describe('BrowseByTitlePageComponent', () => { let comp: BrowseByTitlePageComponent; 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 b2798b7fa8..6504a8700a 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 @@ -45,7 +45,8 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; }) ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + this.startsWith = +params.startsWith || params.startsWith; + this.browseId = params.id || this.defaultBrowseId; this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId), undefined, undefined); this.updateParent(params.scope); })); 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 e8d8d3eb11..142604c9b2 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 @@ -16,7 +16,7 @@ import { Collection } from '../../core/shared/collection.model'; import { RemoteData } from '../../core/data/remote-data'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { EventEmitter } from '@angular/core'; +import { ChangeDetectionStrategy, EventEmitter } from '@angular/core'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { By } from '@angular/platform-browser'; @@ -41,6 +41,12 @@ import { } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; describe('CollectionItemMapperComponent', () => { let comp: CollectionItemMapperComponent; @@ -110,15 +116,15 @@ describe('CollectionItemMapperComponent', () => { }; const searchServiceStub = Object.assign(new SearchServiceStub(), { search: () => observableOf(emptyList), - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ clearDiscoveryRequests: () => {} - /* tslint:enable:no-empty */ + /* eslint-enable no-empty,@typescript-eslint/no-empty-function */ }); const collectionDataServiceStub = { getMappedItems: () => observableOf(emptyList), - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ clearMappedItemsRequests: () => {} - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ }; const routeServiceStub = { getRouteParameterValue: () => { @@ -141,6 +147,25 @@ describe('CollectionItemMapperComponent', () => { isAuthorized: observableOf(true) }); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], @@ -157,8 +182,19 @@ describe('CollectionItemMapperComponent', () => { { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: RouteService, useValue: routeServiceStub }, - { provide: AuthorizationDataService, useValue: authorizationDataService } + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, ] + }).overrideComponent(CollectionItemMapperComponent, { + set: { + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationServiceStub + } + ] } }).compileComponents(); })); diff --git a/src/app/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 5879e523af..678c745c01 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -6,7 +6,7 @@ import { CreateCollectionPageComponent } from './create-collection-page/create-c import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; -import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component'; +import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component'; import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver'; import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; @@ -18,9 +18,9 @@ import { COLLECTION_CREATE_PATH } from './collection-page-routing-paths'; import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard'; -import { MenuItemType } from '../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; @NgModule({ imports: [ @@ -52,7 +52,7 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen }, { path: ITEMTEMPLATE_PATH, - component: EditItemTemplatePageComponent, + component: ThemedEditItemTemplatePageComponent, canActivate: [AuthenticatedGuard], resolve: { item: ItemTemplatePageResolver, diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 9d598a3b69..c1df38f793 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -1,8 +1,8 @@
-
-
+
+
@@ -13,8 +13,7 @@ + [alternateText]="'Collection Logo'"> @@ -34,16 +33,16 @@ [title]="'collection.page.news'">
-
+
- - +
@@ -57,8 +56,8 @@
- + @@ -75,7 +74,7 @@
- +
diff --git a/src/app/collection-page/collection-page.component.ts b/src/app/collection-page/collection-page.component.ts index be602f8458..09471d4c6d 100644 --- a/src/app/collection-page/collection-page.component.ts +++ b/src/app/collection-page/collection-page.component.ts @@ -16,7 +16,6 @@ import { Item } from '../core/shared/item.model'; import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteData, - redirectOn4xx, toDSpaceObjectListRD } from '../core/shared/operators'; @@ -28,6 +27,7 @@ import { PaginationService } from '../core/pagination/pagination.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { getCollectionPageRoute } from './collection-page-routing-paths'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; @Component({ selector: 'ds-collection-page', diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index 3652823200..c35ebf9021 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -8,6 +8,7 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component'; +import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component'; import { EditItemPageModule } from '../item-page/edit-item-page/edit-item-page.module'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; import { SearchService } from '../core/shared/search/search.service'; @@ -32,6 +33,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module'; CreateCollectionPageComponent, DeleteCollectionPageComponent, EditItemTemplatePageComponent, + ThemedEditItemTemplatePageComponent, CollectionItemMapperComponent ], providers: [ diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html index 4abb149498..c33675a752 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.html +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.html @@ -5,11 +5,11 @@

{{ 'collection.delete.text' | translate:{ dso: dso.name } }}

-
+
- 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 3e30373070..1876936efb 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 @@ -5,7 +5,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s 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'; /** * Component that represents the page where a user can delete an existing Collection @@ -24,8 +23,7 @@ export class DeleteCollectionPageComponent extends DeleteComColPageComponent -
+
- +

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

@@ -43,7 +43,7 @@
-
+
- +
diff --git a/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts new file mode 100644 index 0000000000..b53f4e6c45 --- /dev/null +++ b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { EditItemTemplatePageComponent } from './edit-item-template-page.component'; + +@Component({ + selector: 'ds-themed-edit-item-template-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +/** + * Component for editing the item template of a collection + */ +export class ThemedEditItemTemplatePageComponent extends ThemedComponent { + protected getComponentName(): string { + return 'EditItemTemplatePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/collection-page/edit-item-template-page/edit-item-template-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-item-template-page.component'); + } +} diff --git a/src/app/collection-page/themed-collection-page.component.ts b/src/app/collection-page/themed-collection-page.component.ts index 82074e43e6..2faf418423 100644 --- a/src/app/collection-page/themed-collection-page.component.ts +++ b/src/app/collection-page/themed-collection-page.component.ts @@ -6,7 +6,7 @@ import { CollectionPageComponent } from './collection-page.component'; * Themed wrapper for CollectionPageComponent */ @Component({ - selector: 'ds-themed-community-page', + selector: 'ds-themed-collection-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', }) diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 02774b794c..e2a2bb748f 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,9 +1,10 @@ -import { FindListOptions } from '../core/data/request.models'; import { hasValue } from '../shared/empty.util'; -import { CommunityListService, FlatNode } from './community-list-service'; +import { CommunityListService} from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { finalize } from 'rxjs/operators'; +import { FlatNode } from './flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; /** * DataSource object needed by a CDK Tree to render its nodes. diff --git a/src/app/community-list-page/community-list-page.component.html b/src/app/community-list-page/community-list-page.component.html index 08accdc0e5..9759f4405d 100644 --- a/src/app/community-list-page/community-list-page.component.html +++ b/src/app/community-list-page/community-list-page.component.html @@ -1,4 +1,4 @@

{{ 'communityList.title' | translate }}

- +
diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 3b009e227c..18c28068be 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -5,12 +5,14 @@ import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListComponent } from './community-list/community-list.component'; import { ThemedCommunityListPageComponent } from './themed-community-list-page.component'; +import { ThemedCommunityListComponent } from './community-list/themed-community-list.component'; const DECLARATIONS = [ CommunityListPageComponent, CommunityListComponent, - ThemedCommunityListPageComponent + ThemedCommunityListPageComponent, + ThemedCommunityListComponent ]; /** * The page which houses a title and the community list, as described in community-list.component diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index fe53a98257..401ffe0b11 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -7,13 +7,14 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo import { buildPaginatedList } from '../core/data/paginated-list.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { StoreMock } from '../shared/testing/store.mock'; -import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; +import { CommunityListService, toFlatNode } from './community-list-service'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityDataService } from '../core/data/community-data.service'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; -import { FindListOptions } from '../core/data/request.models'; import { PageInfo } from '../core/shared/page-info.model'; +import { FlatNode } from './flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; describe('CommunityListService', () => { let store: StoreMock; diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 76d33585da..89b68812ae 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { createSelector, Store } from '@ngrx/store'; @@ -6,45 +7,22 @@ import { filter, map, switchMap } from 'rxjs/operators'; import { AppState } from '../app.reducer'; import { CommunityDataService } from '../core/data/community-data.service'; -import { FindListOptions } from '../core/data/request.models'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; -import { PaginatedList, buildPaginatedList } from '../core/data/paginated-list.model'; +import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListState } from './community-list.reducer'; import { getCommunityPageRoute } from '../community-page/community-page-routing-paths'; import { getCollectionPageRoute } from '../collection-page/collection-page-routing-paths'; -import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../core/shared/operators'; import { followLink } from '../shared/utils/follow-link-config.model'; - -/** - * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and - * state in the tree. There are nodes representing communities, collections and show more links. - */ -export interface FlatNode { - isExpandable$: Observable; - name: string; - id: string; - level: number; - isExpanded?: boolean; - parent?: FlatNode; - payload: Community | Collection | ShowMoreFlatNode; - isShowMoreNode: boolean; - route?: string; - currentCommunityPage?: number; - currentCollectionPage?: number; -} - -/** - * The show more links in the community tree are also represented by a flatNode so we know where in - * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) - */ -export class ShowMoreFlatNode { -} +import { FlatNode } from './flat-node.model'; +import { ShowMoreFlatNode } from './show-more-flat-node.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; // Helper method to combine an flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Observable[]): Observable => @@ -108,7 +86,6 @@ export const MAX_COMCOLS_PER_PAGE = 20; * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * and connection to the store to retrieve and save the state of the community list */ -// tslint:disable-next-line:max-classes-per-file @Injectable() export class CommunityListService { diff --git a/src/app/community-list-page/community-list.actions.ts b/src/app/community-list-page/community-list.actions.ts index 1d2f732ac4..8e8d6d87cf 100644 --- a/src/app/community-list-page/community-list.actions.ts +++ b/src/app/community-list-page/community-list.actions.ts @@ -1,6 +1,6 @@ import { Action } from '@ngrx/store'; import { type } from '../shared/ngrx/type'; -import { FlatNode } from './community-list-service'; +import { FlatNode } from './flat-node.model'; /** * All the action types of the community-list diff --git a/src/app/community-list-page/community-list.reducer.ts b/src/app/community-list-page/community-list.reducer.ts index 236201b353..99c8350cf4 100644 --- a/src/app/community-list-page/community-list.reducer.ts +++ b/src/app/community-list-page/community-list.reducer.ts @@ -1,5 +1,5 @@ -import { FlatNode } from './community-list-service'; import { CommunityListActions, CommunityListActionTypes, CommunityListSaveAction } from './community-list.actions'; +import { FlatNode } from './flat-node.model'; /** * States we wish to put in store concerning the community list diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index f441dfa36e..821cb58473 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -1,4 +1,4 @@ - +
@@ -57,7 +57,7 @@ - +
diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index 1f020b7744..575edf14e8 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { CommunityListComponent } from './community-list.component'; -import { CommunityListService, FlatNode, showMoreFlatNode, toFlatNode } from '../community-list-service'; +import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community-list-service'; import { CdkTreeModule } from '@angular/cdk/tree'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; @@ -15,6 +15,7 @@ import { Collection } from '../../core/shared/collection.model'; import { of as observableOf } from 'rxjs'; import { By } from '@angular/platform-browser'; import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { FlatNode } from '../flat-node.model'; describe('CommunityListComponent', () => { let component: CommunityListComponent; diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index 49065c5ec5..556387da25 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,11 +1,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; -import { CommunityListService, FlatNode } from '../community-list-service'; +import { CommunityListService} from '../community-list-service'; import { CommunityListDatasource } from '../community-list-datasource'; import { FlatTreeControl } from '@angular/cdk/tree'; import { isEmpty } from '../../shared/empty.util'; +import { FlatNode } from '../flat-node.model'; +import { FindListOptions } from '../../core/data/find-list-options.model'; /** * A tree-structured list of nodes representing the communities, their subCommunities and collections. diff --git a/src/app/community-list-page/community-list/themed-community-list.component.ts b/src/app/community-list-page/community-list/themed-community-list.component.ts new file mode 100644 index 0000000000..4a986e737c --- /dev/null +++ b/src/app/community-list-page/community-list/themed-community-list.component.ts @@ -0,0 +1,24 @@ +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { CommunityListComponent } from './community-list.component'; +import { Component } from '@angular/core'; + + +@Component({ + selector: 'ds-themed-community-list', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedCommunityListComponent extends ThemedComponent { + protected getComponentName(): string { + return 'CommunityListComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/community-list-page/community-list/community-list.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./community-list.component`); + } + +} diff --git a/src/app/community-list-page/flat-node.model.ts b/src/app/community-list-page/flat-node.model.ts new file mode 100644 index 0000000000..0aabbeb489 --- /dev/null +++ b/src/app/community-list-page/flat-node.model.ts @@ -0,0 +1,22 @@ +import { Observable } from 'rxjs'; +import { Community } from '../core/shared/community.model'; +import { Collection } from '../core/shared/collection.model'; +import { ShowMoreFlatNode } from './show-more-flat-node.model'; + +/** + * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and + * state in the tree. There are nodes representing communities, collections and show more links. + */ +export interface FlatNode { + isExpandable$: Observable; + name: string; + id: string; + level: number; + isExpanded?: boolean; + parent?: FlatNode; + payload: Community | Collection | ShowMoreFlatNode; + isShowMoreNode: boolean; + route?: string; + currentCommunityPage?: number; + currentCollectionPage?: number; +} diff --git a/src/app/community-list-page/show-more-flat-node.model.ts b/src/app/community-list-page/show-more-flat-node.model.ts new file mode 100644 index 0000000000..801c9e7388 --- /dev/null +++ b/src/app/community-list-page/show-more-flat-node.model.ts @@ -0,0 +1,6 @@ +/** + * The show more links in the community tree are also represented by a flatNode so we know where in + * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) + */ +export class ShowMoreFlatNode { +} diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts index ad1b1fd2f2..25326448a8 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -11,9 +11,9 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi import { LinkService } from '../core/cache/builders/link.service'; import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; -import { MenuItemType } from '../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedCommunityPageComponent } from './themed-community-page.component'; +import { MenuItemType } from '../shared/menu/menu-item-type.model'; @NgModule({ imports: [ diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index cf7282eb4b..6b277bd07f 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -20,14 +20,14 @@ [title]="'community.page.news'"> -
+
- - + + @@ -41,5 +41,5 @@
- +
diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index 70259a599b..b1a0cfc946 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -13,11 +13,12 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../core/shared/operators'; +import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; import { AuthService } from '../core/auth/auth.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { getCommunityPageRoute } from './community-page-routing-paths'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; @Component({ selector: 'ds-community-page', diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.html b/src/app/community-page/delete-community-page/delete-community-page.component.html index 658f3da436..751d001c51 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.html +++ b/src/app/community-page/delete-community-page/delete-community-page.component.html @@ -5,11 +5,11 @@

{{ 'community.delete.text' | translate:{ dso: dso.name } }}

-
+
- 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 0cccc503e1..6e640c64be 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 @@ -5,7 +5,6 @@ import { ActivatedRoute, Router } from '@angular/router'; 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'; /** * Component that represents the page where a user can delete an existing Community @@ -24,9 +23,8 @@ export class DeleteCommunityPageComponent extends DeleteComColPageComponent { @@ -64,6 +66,7 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, + { provide: NotificationsService, useClass: NotificationsServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html index 9928ebd18a..69f16ee3ac 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -9,5 +9,5 @@
- + diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index 93a6c6fbb1..c0ce5369ff 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -11,7 +11,6 @@ import { CommunityPageSubCollectionListComponent } from './community-page-sub-co import { Community } from '../../core/shared/community.model'; import { SharedModule } from '../../shared/shared.module'; import { CollectionDataService } from '../../core/data/collection-data.service'; -import { FindListOptions } from '../../core/data/request.models'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { buildPaginatedList } from '../../core/data/paginated-list.model'; import { PageInfo } from '../../core/shared/page-info.model'; @@ -25,6 +24,15 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchServiceStub } from '../../shared/testing/search-service.stub'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; describe('CommunityPageSubCollectionList Component', () => { let comp: CommunityPageSubCollectionListComponent; @@ -122,6 +130,25 @@ describe('CommunityPageSubCollectionList Component', () => { themeService = getMockThemeService(); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -138,6 +165,10 @@ describe('CommunityPageSubCollectionList Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html index 2d14dce60a..be2788a9f4 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html @@ -9,5 +9,5 @@
- + diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index e573259b63..3392ada994 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -13,7 +13,6 @@ import { buildPaginatedList } from '../../core/data/paginated-list.model'; import { PageInfo } from '../../core/shared/page-info.model'; import { SharedModule } from '../../shared/shared.module'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FindListOptions } from '../../core/data/request.models'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { CommunityDataService } from '../../core/data/community-data.service'; @@ -25,6 +24,14 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('CommunityPageSubCommunityListComponent Component', () => { let comp: CommunityPageSubCommunityListComponent; @@ -119,6 +126,25 @@ describe('CommunityPageSubCommunityListComponent Component', () => { } }; + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + const paginationService = new PaginationServiceStub(); themeService = getMockThemeService(); @@ -139,6 +165,10 @@ describe('CommunityPageSubCommunityListComponent Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 00a94822d3..4db4cba612 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -3,7 +3,7 @@ import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxj import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { isNotEmpty } from '../../shared/empty.util'; -import { GetRequest, PostRequest, RestRequest, } from '../data/request.models'; +import { GetRequest, PostRequest, } from '../data/request.models'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -11,13 +11,14 @@ import { RemoteData } from '../data/remote-data'; import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { RestRequest } from '../data/rest-request.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Abstract service to send authentication requests */ export abstract class AuthRequestService { protected linkName = 'authn'; - protected browseEndpoint = ''; protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, @@ -26,14 +27,21 @@ export abstract class AuthRequestService { ) { } - protected fetchRequest(request: RestRequest): Observable> { - return this.rdbService.buildFromRequestUUID(request.uuid).pipe( + protected fetchRequest(request: RestRequest, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.rdbService.buildFromRequestUUID(request.uuid, ...linksToFollow).pipe( getFirstCompletedRemoteData(), ); } - protected getEndpointByMethod(endpoint: string, method: string): string { - return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + protected getEndpointByMethod(endpoint: string, method: string, ...linksToFollow: FollowLinkConfig[]): string { + let url = isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + if (linksToFollow?.length > 0) { + linksToFollow.forEach((link: FollowLinkConfig, index: number) => { + url += ((index === 0) ? '?' : '&') + `embed=${link.name}`; + }); + } + + return url; } public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable> { @@ -47,14 +55,14 @@ export abstract class AuthRequestService { distinctUntilChanged()); } - public getRequest(method: string, options?: HttpOptions): Observable> { + public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), - map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), + map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), tap((request: GetRequest) => this.requestService.send(request)), - mergeMap((request: GetRequest) => this.fetchRequest(request)), + mergeMap((request: GetRequest) => this.fetchRequest(request, ...linksToFollow)), distinctUntilChanged()); } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 15e42c8576..60440d371e 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ // import @ngrx import { Action } from '@ngrx/store'; // import type function @@ -39,7 +40,6 @@ export const AuthActionTypes = { UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') }; -/* tslint:disable:max-classes-per-file */ /** * Authenticate. @@ -411,7 +411,6 @@ export class SetUserAsIdleAction implements Action { export class UnsetUserAsIdleAction implements Action { public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; } -/* tslint:enable:max-classes-per-file */ /** * Actions type. diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index ed91eb3eea..f09db04d99 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,4 +1,4 @@ -import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Store, StoreModule } from '@ngrx/store'; @@ -219,6 +219,9 @@ describe('AuthEffects', () => { const expected = cold('--b-', { b: new RetrieveTokenAction() }); expect(authEffects.checkTokenCookie$).toBeObservable(expected); + authEffects.checkTokenCookie$.subscribe(() => { + expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); + }); }); it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { @@ -393,44 +396,43 @@ describe('AuthEffects', () => { }); describe('when auth loaded is false', () => { - it('should not call removeToken method', (done) => { + it('should not call removeToken method', fakeAsync(() => { store.overrideSelector(isAuthenticatedLoaded, false); - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); spyOn(authServiceStub, 'removeToken'); authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { - expect(authServiceStub.removeToken).not.toHaveBeenCalled(); - + expect(false).toBeTrue(); // subscribe to trigger taps, fail if the effect emits (we don't expect it to) }); - - done(); - }); + tick(1000); + expect(authServiceStub.removeToken).not.toHaveBeenCalled(); + })); }); describe('when auth loaded is true', () => { - it('should call removeToken method', fakeAsync(() => { + it('should call removeToken method', (done) => { + spyOn(console, 'log').and.callThrough(); + store.overrideSelector(isAuthenticatedLoaded, true); - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); spyOn(authServiceStub, 'removeToken'); authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { expect(authServiceStub.removeToken).toHaveBeenCalled(); - flush(); + done(); }); - - })); + }); }); }); describe('invalidateAuthorizationsRequestCache$', () => { it('should call invalidateAuthorizationsRequestCache method in response to a REHYDRATE action', (done) => { - actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + actions = observableOf({ type: StoreActionTypes.REHYDRATE }); authEffects.invalidateAuthorizationsRequestCache$.subscribe(() => { expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); + done(); }); - - done(); }); }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 1477a1832e..22d1bf35e7 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -10,7 +10,7 @@ import { } from 'rxjs'; import { catchError, filter, map, observeOn, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; // import services @@ -67,8 +67,7 @@ export class AuthEffects { * Authenticate user. * @method authenticate */ - @Effect() - public authenticate$: Observable = this.actions$.pipe( + public authenticate$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE), switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password).pipe( @@ -77,26 +76,23 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticationErrorAction(error))) ); }) - ); + )); - @Effect() - public authenticateSuccess$: Observable = this.actions$.pipe( + public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) - ); + )); - @Effect() - public authenticated$: Observable = this.actions$.pipe( + public authenticated$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED), switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); }) - ); + )); - @Effect() - public authenticatedSuccess$: Observable = this.actions$.pipe( + public authenticatedSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( @@ -110,26 +106,23 @@ export class AuthEffects { return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); } }) - ); + )); - @Effect({ dispatch: false }) - public redirectAfterLoginSuccess$: Observable = this.actions$.pipe( + public redirectAfterLoginSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS), tap((action: RedirectAfterLoginSuccessAction) => { this.authService.clearRedirectUrl(); this.authService.navigateToRedirectUrl(action.payload); }) - ); + ), { dispatch: false }); // It means "reacts to this action but don't send another" - @Effect({ dispatch: false }) - public authenticatedError$: Observable = this.actions$.pipe( + public authenticatedError$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_ERROR), tap((action: LogOutSuccessAction) => this.authService.removeToken()) - ); + ), { dispatch: false }); - @Effect() - public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( + public retrieveAuthenticatedEperson$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON), switchMap((action: RetrieveAuthenticatedEpersonAction) => { const impersonatedUserID = this.authService.getImpersonateID(); @@ -143,25 +136,24 @@ export class AuthEffects { map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); }) - ); + )); - @Effect() - public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), + public checkToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { return this.authService.hasValidAuthenticationToken().pipe( map((token: AuthTokenInfo) => new AuthenticatedAction(token)), catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) ); }) - ); + )); - @Effect() - public checkTokenCookie$: Observable = this.actions$.pipe( + public checkTokenCookie$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE), switchMap(() => { return this.authService.checkAuthenticationCookie().pipe( map((response: AuthStatus) => { if (response.authenticated) { + this.authorizationsService.invalidateAuthorizationsRequestCache(); return new RetrieveTokenAction(); } else { return new RetrieveAuthMethodsAction(response); @@ -170,10 +162,9 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticatedErrorAction(error))) ); }) - ); + )); - @Effect() - public retrieveToken$: Observable = this.actions$.pipe( + public retrieveToken$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_TOKEN), switchMap((action: AuthenticateAction) => { return this.authService.refreshAuthenticationToken(null).pipe( @@ -182,55 +173,51 @@ export class AuthEffects { catchError((error) => observableOf(new AuthenticationErrorAction(error))) ); }) - ); + )); - @Effect() - public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), + public refreshToken$: Observable = createEffect(() => this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), switchMap((action: RefreshTokenAction) => { return this.authService.refreshAuthenticationToken(action.payload).pipe( map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), catchError((error) => observableOf(new RefreshTokenErrorAction())) ); }) - ); + )); // It means "reacts to this action but don't send another" - @Effect({ dispatch: false }) - public refreshTokenSuccess$: Observable = this.actions$.pipe( + public refreshTokenSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) - ); + ), { dispatch: false }); /** * When the store is rehydrated in the browser, * clear a possible invalid token or authentication errors */ - @Effect({ dispatch: false }) - public clearInvalidTokenOnRehydrate$: Observable = this.actions$.pipe( + public clearInvalidTokenOnRehydrate$: Observable = createEffect(() => this.actions$.pipe( ofType(StoreActionTypes.REHYDRATE), switchMap(() => { const isLoaded$ = this.store.pipe(select(isAuthenticatedLoaded)); const authenticated$ = this.store.pipe(select(isAuthenticated)); - return observableCombineLatest(isLoaded$, authenticated$).pipe( + return observableCombineLatest([isLoaded$, authenticated$]).pipe( take(1), filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), tap(() => this.authService.resetAuthenticationError()) ); - })); + })), { dispatch: false }); /** * When the store is rehydrated in the browser, invalidate all cache hits regarding the * authorizations endpoint, to be sure to have consistent responses after a login with external idp * */ - @Effect({ dispatch: false }) invalidateAuthorizationsRequestCache$ = this.actions$ + invalidateAuthorizationsRequestCache$ = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), tap(() => this.authorizationsService.invalidateAuthorizationsRequestCache()) - ); + ), { dispatch: false }); - @Effect() - public logOut$: Observable = this.actions$ + public logOut$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.LOG_OUT), switchMap(() => { @@ -240,26 +227,23 @@ export class AuthEffects { catchError((error) => observableOf(new LogOutErrorAction(error))) ); }) - ); + )); - @Effect({ dispatch: false }) - public logOutSuccess$: Observable = this.actions$ + public logOutSuccess$: Observable = createEffect(() => this.actions$ .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), tap(() => this.authService.removeToken()), tap(() => this.authService.clearRedirectUrl()), tap(() => this.authService.refreshAfterLogout()) - ); + ), { dispatch: false }); - @Effect({ dispatch: false }) - public redirectToLoginTokenExpired$: Observable = this.actions$ + public redirectToLoginTokenExpired$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), tap(() => this.authService.removeToken()), tap(() => this.authService.redirectToLoginWhenTokenExpired()) - ); + ), { dispatch: false }); - @Effect() - public retrieveMethods$: Observable = this.actions$ + public retrieveMethods$: Observable = createEffect(() => this.actions$ .pipe( ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), switchMap((action: RetrieveAuthMethodsAction) => { @@ -269,7 +253,7 @@ export class AuthEffects { catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) ); }) - ); + )); /** * For any action that is not in {@link IDLE_TIMER_IGNORE_TYPES} that comes in => Start the idleness timer @@ -277,8 +261,7 @@ export class AuthEffects { * => Return the action to set the user as idle ({@link SetUserAsIdleAction}) * @method trackIdleness */ - @Effect() - public trackIdleness$: Observable = this.actions$.pipe( + public trackIdleness$: Observable = createEffect(() => this.actions$.pipe( filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), // Using switchMap the effect will stop subscribing to the previous timer if a new action comes // in, and start a new timer @@ -289,7 +272,7 @@ export class AuthEffects { // Re-enter the zone to dispatch the action observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), map(() => new SetUserAsIdleAction()), - ); + )); /** * @constructor diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 029deb5326..04bbc4acaf 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -20,9 +20,9 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* tslint:disable:no-empty */ + /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* tslint:enable:no-empty */ + /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ select: observableOf(true) }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index a49030110b..e55d0c0ff9 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -144,7 +144,7 @@ export class AuthInterceptor implements HttpInterceptor { const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g; const realms = completeWWWauthenticateHeader.match(regex); - // tslint:disable-next-line:forin + // eslint-disable-next-line guard-for-in for (const j in realms) { const splittedRealm = realms[j].split(', '); diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 8cd587b61a..8ebc9f6cb0 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -192,7 +192,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -212,7 +212,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -558,7 +558,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, authMethods: [], idle: false diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 2fc79a8861..6f47a3c20c 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -92,11 +92,15 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + return Object.assign({}, state, { + loading: true, + blocking: true + }); + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -210,7 +214,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index ced8bb94c8..b38d17aecd 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -32,6 +32,8 @@ import { TranslateService } from '@ngx-translate/core'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; +import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock'; +import { cold } from 'jasmine-marbles'; describe('AuthService test', () => { @@ -56,6 +58,13 @@ describe('AuthService test', () => { let linkService; let hardRedirectService; + const AuthStatusWithSpecialGroups = Object.assign(new AuthStatus(), { + uuid: 'test', + authenticated: true, + okay: true, + specialGroups: SpecialGroupDataMock$ + }); + function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, @@ -368,25 +377,25 @@ describe('AuthService test', () => { it('should redirect to reload with redirect url', () => { authService.navigateToRedirectUrl('/collection/123'); // Reload with redirect URL set to /collection/123 - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); it('should redirect to reload with /home', () => { authService.navigateToRedirectUrl('/home'); // Reload with redirect URL set to /home - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); it('should redirect to regular reload and not to /login', () => { authService.navigateToRedirectUrl('/login'); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); it('should redirect to regular reload when no redirect url is found', () => { authService.navigateToRedirectUrl(undefined); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { @@ -511,6 +520,19 @@ describe('AuthService test', () => { expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); }); }); + + describe('getSpecialGroupsFromAuthStatus', () => { + beforeEach(() => { + spyOn(authRequest, 'getRequest').and.returnValue(createSuccessfulRemoteDataObject$(AuthStatusWithSpecialGroups)); + }); + + it('should call navigateToRedirectUrl with no url', () => { + const expectRes = cold('(a|)', { + a: SpecialGroupDataMock + }); + expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); + }); + }); }); describe('when user is not logged in', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 5738948ebd..3034c00197 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { map, startWith, switchMap, take } from 'rxjs/operators'; +import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -44,13 +44,18 @@ import { import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload } from '../shared/operators'; +import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RemoteData } from '../data/remote-data'; import { environment } from '../../../environments/environment'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model'; +import { Group } from '../eperson/models/group.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PageInfo } from '../shared/page-info.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -88,6 +93,8 @@ export class AuthService { private translateService: TranslateService ) { this.store.pipe( + // when this service is constructed the store is not fully initialized yet + filter((state: any) => state?.core?.auth !== undefined), select(isAuthenticated), startWith(false) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); @@ -112,7 +119,7 @@ export class AuthService { if (hasValue(rd.payload) && rd.payload.authenticated) { return rd.payload; } else { - throw(new Error('Invalid email or password')); + throw (new Error('Invalid email or password')); } })); @@ -166,7 +173,7 @@ export class AuthService { if (hasValue(status) && status.authenticated) { return status._links.eperson.href; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } @@ -211,6 +218,22 @@ export class AuthService { this.store.dispatch(new CheckAuthenticationTokenAction()); } + /** + * Return the special groups list embedded in the AuthStatus model + */ + public getSpecialGroupsFromAuthStatus(): Observable>> { + return this.authRequestService.getRequest('status', null, followLink('specialGroups')).pipe( + getFirstCompletedRemoteData(), + switchMap((status: RemoteData) => { + if (status.hasSucceeded) { + return status.payload.specialGroups; + } else { + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); + } + }) + ); + } + /** * Checks if token is present into storage and is not expired */ @@ -249,7 +272,7 @@ export class AuthService { if (hasValue(status) && status.authenticated) { return status.token; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } @@ -288,7 +311,7 @@ export class AuthService { if (hasValue(status) && !status.authenticated) { return true; } else { - throw(new Error('auth.errors.invalid-user')); + throw (new Error('auth.errors.invalid-user')); } })); } @@ -325,7 +348,7 @@ export class AuthService { let token: AuthTokenInfo; let currentlyRefreshingToken = false; this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { - // If new token is undefined an it wasn't previously => Refresh failed + // If new token is undefined and it wasn't previously => Refresh failed if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); @@ -447,8 +470,8 @@ export class AuthService { */ public navigateToRedirectUrl(redirectUrl: string) { // Don't do redirect if already on reload url - if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { - let url = `/reload/${new Date().getTime()}`; + if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) { + let url = `reload/${new Date().getTime()}`; if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { url += `?redirect=${encodeURIComponent(redirectUrl)}`; } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 0b9eeec509..1ab1d2e0a5 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -11,9 +11,9 @@ import { Observable } from 'rxjs'; import { map, find, switchMap } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { AuthService, LOGIN_ROUTE } from './auth.service'; +import { CoreState } from '../core-state.model'; /** * Prevent unauthorized activating and loading of routes diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 197c025407..d18b1ccf9a 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -2,10 +2,11 @@ import { autoserialize, deserialize, deserializeAs } from 'cerialize'; import { Observable } from 'rxjs'; import { link, typedObject } from '../../cache/builders/build-decorators'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; -import { CacheableObject } from '../../cache/object-cache.reducer'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { Group } from '../../eperson/models/group.model'; +import { GROUP } from '../../eperson/models/group.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; @@ -13,6 +14,8 @@ import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; import { AuthMethod } from './auth.method'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { PaginatedList } from '../../data/paginated-list.model'; /** * Object that represents the authenticated status of a user @@ -61,6 +64,7 @@ export class AuthStatus implements CacheableObject { _links: { self: HALLink; eperson: HALLink; + specialGroups: HALLink; }; /** @@ -70,6 +74,13 @@ export class AuthStatus implements CacheableObject { @link(EPERSON) eperson?: Observable>; + /** + * The SpecialGroup of this auth status + * Will be undefined unless the SpecialGroup {@link HALLink} has been resolved. + */ + @link(GROUP, true) + specialGroups?: Observable>>; + /** * True if the token is valid, false if there was no token or the token wasn't valid */ diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 9d999c4c3f..594d6d8b39 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -4,5 +4,6 @@ export enum AuthMethodType { Ldap = 'ldap', Ip = 'ip', X509 = 'x509', - Oidc = 'oidc' + Oidc = 'oidc', + Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 5a362e8606..0579ae0cd1 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -34,6 +34,11 @@ export class AuthMethod { this.location = location; break; } + case 'orcid': { + this.authMethodType = AuthMethodType.Orcid; + this.location = location; + break; + } default: { break; diff --git a/src/app/core/auth/models/short-lived-token.model.ts b/src/app/core/auth/models/short-lived-token.model.ts index 118c724328..3786bd8e6a 100644 --- a/src/app/core/auth/models/short-lived-token.model.ts +++ b/src/app/core/auth/models/short-lived-token.model.ts @@ -1,10 +1,10 @@ -import { CacheableObject } from '../../cache/object-cache.reducer'; import { typedObject } from '../../cache/builders/build-decorators'; import { excludeFromEquals } from '../../utilities/equals.decorators'; import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { ResourceType } from '../../shared/resource-type'; import { SHORT_LIVED_TOKEN } from './short-lived-token.resource-type'; import { HALLink } from '../../shared/hal-link.model'; +import { CacheableObject } from '../../cache/cacheable-object.model'; /** * A short-lived token that can be used to authenticate a rest request diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 9ee9f7eb2e..1d002b3908 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -8,6 +8,8 @@ import { createSelector } from '@ngrx/store'; */ import { AuthState } from './auth.reducer'; import { AppState } from '../../app.reducer'; +import { CoreState } from '../core-state.model'; +import { coreSelector } from '../core.selectors'; /** * Returns the user state. @@ -15,7 +17,7 @@ import { AppState } from '../../app.reducer'; * @param {AppState} state Top level state. * @return {AuthState} */ -export const getAuthState = (state: any) => state.core.auth; +export const getAuthState = createSelector(coreSelector, (state: CoreState) => state.auth); /** * Returns true if the user is authenticated. diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index ea5a3b41f2..fc8ab18bfb 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -36,7 +36,7 @@ export class ServerAuthService extends AuthService { if (hasValue(status) && status.authenticated) { return status._links.eperson.href; } else { - throw(new Error('Not authenticated')); + throw (new Error('Not authenticated')); } })); } diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts index d39b3cc33d..1ba7a16b14 100644 --- a/src/app/core/auth/token-response-parsing.service.ts +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -1,9 +1,9 @@ import { ResponseParsingService } from '../data/parsing.service'; -import { RestRequest } from '../data/request.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestResponse, TokenResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { Injectable } from '@angular/core'; +import { RestRequest } from '../data/rest-request.model'; @Injectable() /** diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts new file mode 100644 index 0000000000..b2ddade682 --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Bitstream } from '../shared/bitstream.model'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; + +/** + * The class that resolves the BreadcrumbConfig object for an Item + */ +@Injectable({ + providedIn: 'root' +}) +export class BitstreamBreadcrumbResolver extends DSOBreadcrumbResolver { + constructor( + protected breadcrumbService: BitstreamBreadcrumbsService, protected dataService: BitstreamDataService) { + super(breadcrumbService, dataService); + } + + /** + * Method that returns the follow links to already resolve + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ + get followLinks(): FollowLinkConfig[] { + return BITSTREAM_PAGE_LINKS_TO_FOLLOW; + } + +} diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts new file mode 100644 index 0000000000..333886ed3d --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { DSONameService } from './dso-name.service'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { LinkService } from '../cache/builders/link.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { RemoteData } from '../data/remote-data'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { getDSORoute } from '../../app-routing-paths'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { Item } from '../shared/item.model'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; + +/** + * Service to calculate DSpaceObject breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { + constructor( + protected bitstreamService: BitstreamDataService, + protected linkService: LinkService, + protected dsoNameService: DSONameService + ) { + super(linkService, dsoNameService); + } + + /** + * Method to recursively calculate the breadcrumbs + * This method returns the name and url of the key and all its parent DSOs recursively, top down + * @param key The key (a DSpaceObject) used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: ChildHALResource & DSpaceObject, url: string): Observable { + const label = this.dsoNameService.getName(key); + const crumb = new Breadcrumb(label, url); + + return this.getOwningItem(key.uuid).pipe( + switchMap((parentRD: RemoteData) => { + if (isNotEmpty(parentRD) && hasValue(parentRD.payload)) { + const parent = parentRD.payload; + return super.getBreadcrumbs(parent, getDSORoute(parent)); + } + return observableOf([]); + + }), + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + ); + } + + getOwningItem(uuid: string): Observable> { + return this.bitstreamService.findById(uuid, true, true, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bitstream: Bitstream) => { + if (hasValue(bitstream)) { + return bitstream.bundle.pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bundle: Bundle) => { + if (hasValue(bundle)) { + return bundle.item.pipe( + getFirstCompletedRemoteData(), + ); + } else { + return observableOf(undefined); + } + }) + ); + } else { + return observableOf(undefined); + } + }) + ); + } +} diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 23fff18537..a5884ca3c9 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -20,8 +20,8 @@ import { getDSORoute } from '../../app-routing-paths'; }) export class DSOBreadcrumbsService implements BreadcrumbsProviderService { constructor( - private linkService: LinkService, - private dsoNameService: DSONameService + protected linkService: LinkService, + protected dsoNameService: DSONameService ) { } diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 7a399ce748..9f2f76599a 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -78,15 +78,32 @@ describe(`DSONameService`, () => { }); describe(`factories.Person`, () => { - beforeEach(() => { - spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + describe(`with person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + }); + + it(`should return 'person.familyName, person.givenName'`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title'); + }); }); - it(`should return 'person.familyName, person.givenName'`, () => { - const result = (service as any).factories.Person(mockPerson); - expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + describe(`without person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(undefined, undefined, mockPersonName); + }); + + it(`should return dc.title`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 38363d1989..02ead1615c 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; @@ -27,7 +27,13 @@ export class DSONameService { */ private readonly factories = { Person: (dso: DSpaceObject): string => { - return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`; + const familyName = dso.firstMetadataValue('person.familyName'); + const givenName = dso.firstMetadataValue('person.givenName'); + if (isEmpty(familyName) && isEmpty(givenName)) { + return dso.firstMetadataValue('dc.title') || dso.name; + } else { + return `${familyName}, ${givenName}`; + } }, OrgUnit: (dso: DSpaceObject): string => { return dso.firstMetadataValue('organization.legalName'); 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 d6770f80c0..5b57ee96c1 100644 --- a/src/app/core/browse/browse-definition-data.service.spec.ts +++ b/src/app/core/browse/browse-definition-data.service.spec.ts @@ -1,7 +1,7 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service'; -import { FindListOptions } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { EMPTY } from 'rxjs'; +import { FindListOptions } from '../data/find-list-options.model'; describe(`BrowseDefinitionDataService`, () => { let service: BrowseDefinitionDataService; diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index dd66d8fa53..6a27bb3f7a 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { dataService } from '../cache/builders/build-decorators'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; @@ -6,7 +7,6 @@ import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -15,10 +15,10 @@ import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; -import { FindListOptions } from '../data/request.models'; import { PaginatedList } from '../data/paginated-list.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from '../data/find-list-options.model'; -/* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { protected linkPath = 'browses'; @@ -123,4 +123,3 @@ export class BrowseDefinitionDataService { } } -/* 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 ac68fadb31..db802dcbdd 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -5,7 +5,6 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-bu import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; @@ -13,6 +12,7 @@ import { BrowseService } from './browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { RequestEntry } from '../data/request-entry.model'; describe('BrowseService', () => { let scheduler: TestScheduler; diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 0c6074630b..064a2b3f83 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,9 +1,9 @@ +/* eslint-disable max-classes-per-file */ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { dataService, getDataServiceFor, getLinkDefinition, link, } from './build-decorators'; -/* tslint:disable:max-classes-per-file */ class TestService { } @@ -80,4 +80,3 @@ describe('build decorators', () => { }); }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index b561ababde..193eeb57e8 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -4,11 +4,11 @@ import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { - CacheableObject, - TypedObject, getResourceTypeValueFor } from '../object-cache.reducer'; import { InjectionToken } from '@angular/core'; +import { CacheableObject } from '../cacheable-object.model'; +import { TypedObject } from '../typed-object.model'; export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { providedIn: 'root', diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index f567c39314..5e71a45053 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -1,18 +1,18 @@ +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { FindListOptions } from '../../data/request.models'; import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { LinkService } from './link.service'; import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; import { isEmpty } from 'rxjs/operators'; +import { FindListOptions } from '../../data/find-list-options.model'; const TEST_MODEL = new ResourceType('testmodel'); let result: any; -/* tslint:disable:max-classes-per-file */ class TestModel implements HALResource { static type = TEST_MODEL; @@ -251,4 +251,3 @@ describe('LinkService', () => { }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index 0cb45733a6..1d22da494f 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -13,10 +13,11 @@ import { RequestService } from '../../data/request.service'; import { UnCacheableObject } from '../../shared/uncacheable-object.model'; import { RemoteData } from '../../data/remote-data'; import { Observable, of as observableOf } from 'rxjs'; -import { RequestEntry, RequestEntryState } from '../../data/request.reducer'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { take } from 'rxjs/operators'; import { HALLink } from '../../shared/hal-link.model'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { RequestEntry } from '../../data/request-entry.model'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 6b67549f2d..016f6b16f6 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -11,14 +11,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.u import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list.model'; import { RemoteData } from '../../data/remote-data'; -import { - RequestEntry, - ResponseState, - RequestEntryState, - hasSucceeded -} from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/operators'; import { ObjectCacheService } from '../object-cache.service'; import { LinkService } from './link.service'; import { HALLink } from '../../shared/hal-link.model'; @@ -28,6 +21,10 @@ import { HALResource } from '../../shared/hal-resource.model'; import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; import { getResourceTypeValueFor } from '../object-cache.reducer'; +import { hasSucceeded, RequestEntryState } from '../../data/request-entry-state.model'; +import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/request.operators'; +import { RequestEntry } from '../../data/request-entry.model'; +import { ResponseState } from '../../data/response-state.model'; @Injectable() export class RemoteDataBuildService { diff --git a/src/app/core/cache/cacheable-object.model.ts b/src/app/core/cache/cacheable-object.model.ts new file mode 100644 index 0000000000..b7d1609d58 --- /dev/null +++ b/src/app/core/cache/cacheable-object.model.ts @@ -0,0 +1,22 @@ +/* tslint:disable:max-classes-per-file */ +import { HALResource } from '../shared/hal-resource.model'; +import { HALLink } from '../shared/hal-link.model'; +import { TypedObject } from './typed-object.model'; + +/** + * An interface to represent objects that can be cached + * + * A cacheable object should have a self link + */ +export class CacheableObject extends TypedObject implements HALResource { + uuid?: string; + handle?: string; + _links: { + self: HALLink; + }; + // isNew: boolean; + // dirtyType: DirtyType; + // hasDirtyAttributes: boolean; + // changedAttributes: AttributeDiffh; + // save(): void; +} diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index ed509341a7..88b4730b3f 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -1,8 +1,9 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; -import { CacheableObject } from './object-cache.reducer'; import { Operation } from 'fast-json-patch'; +import { CacheableObject } from './cacheable-object.model'; /** * The list of ObjectCacheAction type definitions @@ -15,7 +16,6 @@ export const ObjectCacheActionTypes = { APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') }; -/* tslint:disable:max-classes-per-file */ /** * An ngrx action to add an object to the cache */ @@ -126,7 +126,6 @@ export class ApplyPatchObjectCacheAction implements Action { } } -/* tslint:enable:max-classes-per-file */ /** * A type to encompass all ObjectCacheActions diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index 2bd8ad0e3c..fa2bf6f690 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,6 +1,6 @@ import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,10 +16,10 @@ export class ObjectCacheEffects { * This assumes that the server cached everything a negligible * time ago, and will likely need to be revisited later */ - @Effect() fixTimestampsOnRehydrate = this.actions$ + fixTimestampsOnRehydrate = createEffect(() => this.actions$ .pipe(ofType(StoreActionTypes.REHYDRATE), map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())) - ); + )); constructor(private actions$: Actions) { } diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 077a1e67f8..82e2da58b1 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -41,7 +41,7 @@ describe('objectCacheReducer', () => { alternativeLinks: [altLink1, altLink2], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUID: requestUUID1, + requestUUIDs: [requestUUID1], patches: [], isDirty: false, }, @@ -55,7 +55,7 @@ describe('objectCacheReducer', () => { alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUID: selfLink2, + requestUUIDs: [selfLink2], patches: [], isDirty: false } @@ -105,10 +105,10 @@ describe('objectCacheReducer', () => { const action = new AddToObjectCacheAction(objectToCache, timeCompleted, msToLive, requestUUID, altLink1); const newState = objectCacheReducer(testState, action); - /* tslint:disable:no-string-literal */ + /* eslint-disable @typescript-eslint/dot-notation */ expect(newState[selfLink1].data['foo']).toBe('baz'); expect(newState[selfLink1].data['somethingElse']).toBe(true); - /* tslint:enable:no-string-literal */ + /* eslint-enable @typescript-eslint/dot-notation */ }); it('should perform the ADD action without affecting the previous state', () => { diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 8c1420704c..1a42408f72 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,18 +1,17 @@ -import { HALLink } from '../shared/hal-link.model'; -import { HALResource } from '../shared/hal-resource.model'; +/* eslint-disable max-classes-per-file */ import { + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, ObjectCacheAction, ObjectCacheActionTypes, - AddToObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction, - AddPatchObjectCacheAction, - ApplyPatchObjectCacheAction + ResetObjectCacheTimestampsAction } from './object-cache.actions'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; -import { ResourceType } from '../shared/resource-type'; import { applyPatch, Operation } from 'fast-json-patch'; +import { CacheableObject } from './cacheable-object.model'; /** * An interface to represent a JsonPatch @@ -29,11 +28,6 @@ export interface Patch { operations: Operation[]; } -export abstract class TypedObject { - static type: ResourceType; - type: ResourceType; -} - /** * Get the string value for an object that may be a string or a ResourceType * @@ -49,25 +43,6 @@ export const getResourceTypeValueFor = (type: any): string => { } }; -/* tslint:disable:max-classes-per-file */ -/** - * An interface to represent objects that can be cached - * - * A cacheable object should have a self link - */ -export class CacheableObject extends TypedObject implements HALResource { - uuid?: string; - handle?: string; - _links: { - self: HALLink; - }; - // isNew: boolean; - // dirtyType: DirtyType; - // hasDirtyAttributes: boolean; - // changedAttributes: AttributeDiffh; - // save(): void; -} - /** * An entry in the ObjectCache */ @@ -88,9 +63,11 @@ export class ObjectCacheEntry implements CacheEntry { msToLive: number; /** - * The UUID of the request that caused this entry to be added + * The UUIDs of the requests that caused this entry to be added + * New UUIDs should be added to the front of the array + * to make retrieving the latest UUID easier. */ - requestUUID: string; + requestUUIDs: string[]; /** * An array of patches that were made on the client side to this entry, but haven't been sent to the server yet @@ -110,7 +87,6 @@ export class ObjectCacheEntry implements CacheEntry { alternativeLinks: string[]; } -/* tslint:enable:max-classes-per-file */ /** * The ObjectCache State @@ -182,11 +158,11 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio data: action.payload.objectToCache, timeCompleted: action.payload.timeCompleted, msToLive: action.payload.msToLive, - requestUUID: action.payload.requestUUID, + requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] - } + } as ObjectCacheEntry }); } diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 6863361c34..f18c262524 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -7,7 +7,7 @@ import { Operation } from 'fast-json-patch'; import { empty, of as observableOf } from 'rxjs'; import { first } from 'rxjs/operators'; -import { coreReducers, CoreState } from '../core.reducers'; +import { coreReducers} from '../core.reducers'; import { RestRequestMethod } from '../data/rest-request-method'; import { Item } from '../shared/item.model'; import { @@ -20,10 +20,11 @@ import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; import { AddToSSBAction } from './server-sync-buffer.actions'; import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { IndexName } from '../index/index.reducer'; import { HALLink } from '../shared/hal-link.model'; import { storeModuleConfig } from '../../app.reducer'; import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; +import { IndexName } from '../index/index-name.model'; +import { CoreState } from '../core-state.model'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -210,25 +211,69 @@ describe('ObjectCacheService', () => { }); }); - describe('has', () => { + describe('hasByHref', () => { + describe('with requestUUID not specified', () => { + describe('getByHref emits an object', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry)); + }); - describe('getByHref emits an object', () => { - beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(observableOf(cacheEntry)); + it('should return true', () => { + expect(service.hasByHref(selfLink)).toBe(true); + }); }); - it('should return true', () => { - expect(service.hasByHref(selfLink)).toBe(true); + describe('getByHref emits nothing', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(empty()); + }); + + it('should return false', () => { + expect(service.hasByHref(selfLink)).toBe(false); + }); }); }); - describe('getByHref emits nothing', () => { - beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(empty()); + describe('with requestUUID specified', () => { + describe('getByHref emits an object that includes the specified requestUUID', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, { + requestUUIDs: [ + 'something', + 'something-else', + 'specific-request', + ] + }))); + }); + + it('should return true', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(true); + }); }); - it('should return false', () => { - expect(service.hasByHref(selfLink)).toBe(false); + describe('getByHref emits an object that doesn\'t include the specified requestUUID', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(observableOf(Object.assign(cacheEntry, { + requestUUIDs: [ + 'something', + 'something-else', + ] + }))); + }); + + it('should return true', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(false); + }); + }); + + describe('getByHref emits nothing', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(empty()); + }); + + it('should return false', () => { + expect(service.hasByHref(selfLink, 'specific-request')).toBe(false); + }); }); }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 5fec462670..cdf87e5c1a 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -5,7 +5,7 @@ import { combineLatest as observableCombineLatest, Observable, of as observableO import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core.reducers'; +import { CoreState } from '../core-state.model'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; import { @@ -22,11 +22,12 @@ import { RemoveFromObjectCacheAction } from './object-cache.actions'; -import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; +import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; import { RemoveFromIndexBySubstringAction } from '../index/index.actions'; -import { IndexName } from '../index/index.reducer'; import { HALLink } from '../shared/hal-link.model'; +import { CacheableObject } from './cacheable-object.model'; +import { IndexName } from '../index/index-name.model'; /** * The base selector function to select the object cache in the store @@ -196,7 +197,7 @@ export class ObjectCacheService { */ getRequestUUIDBySelfLink(selfLink: string): Observable { return this.getByHref(selfLink).pipe( - map((entry: ObjectCacheEntry) => entry.requestUUID), + map((entry: ObjectCacheEntry) => entry.requestUUIDs[0]), distinctUntilChanged()); } @@ -281,7 +282,7 @@ export class ObjectCacheService { let result = false; this.getByHref(href).subscribe((entry: ObjectCacheEntry) => { if (isNotEmpty(requestUUID)) { - result = entry.requestUUID === requestUUID; + result = entry.requestUUIDs.includes(requestUUID); } else { result = true; } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3c7c272830..197bf130fb 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,11 +1,11 @@ -import { RequestError } from '../data/request.models'; +/* eslint-disable max-classes-per-file */ import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALLink } from '../shared/hal-link.model'; import { UnCacheableObject } from '../shared/uncacheable-object.model'; +import { RequestError } from '../data/request-error.model'; -/* tslint:disable:max-classes-per-file */ export class RestResponse { public toCache = true; public timeCompleted: number; @@ -140,4 +140,3 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { super(true, statusCode, statusText); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/server-sync-buffer.actions.ts b/src/app/core/cache/server-sync-buffer.actions.ts index 6095083a6c..c07c4e6adf 100644 --- a/src/app/core/cache/server-sync-buffer.actions.ts +++ b/src/app/core/cache/server-sync-buffer.actions.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; @@ -12,7 +13,6 @@ export const ServerSyncBufferActionTypes = { EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'), }; -/* tslint:disable:max-classes-per-file */ /** * An ngrx action to add a new cached object to the server sync buffer @@ -71,7 +71,6 @@ export class EmptySSBAction implements Action { } } -/* tslint:enable:max-classes-per-file */ /** * A type to encompass all ServerSyncBufferActions diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index a53c6af982..833c6b580f 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -83,7 +83,7 @@ describe('ServerSyncBufferEffects', () => { }); it('should return a COMMIT action in response to an ADD action', () => { - // tslint:disable-next-line:no-shadowed-variable + // eslint-disable-next-line @typescript-eslint/no-shadow testScheduler.run(({ hot, expectObservable }) => { actions = hot('a', { a: { diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index d8ed88e12c..9571d4af5b 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,6 +1,6 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { coreSelector } from '../core.selectors'; import { AddToSSBAction, @@ -8,7 +8,6 @@ import { EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; -import { CoreState } from '../core.reducers'; import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; @@ -22,6 +21,7 @@ import { environment } from '../../../environments/environment'; import { ObjectCacheEntry } from './object-cache.reducer'; import { Operation } from 'fast-json-patch'; import { NoOpAction } from '../../shared/ngrx/no-op.action'; +import { CoreState } from '../core-state.model'; @Injectable() export class ServerSyncBufferEffects { @@ -32,7 +32,7 @@ export class ServerSyncBufferEffects { * Then dispatch a CommitSSBAction * When the delay is running, no new AddToSSBActions are processed in this effect */ - @Effect() setTimeoutForServerSync = this.actions$ + setTimeoutForServerSync = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.ADD), exhaustMap((action: AddToSSBAction) => { @@ -42,7 +42,7 @@ export class ServerSyncBufferEffects { delay(timeoutInSeconds * 1000), ); }) - ); + )); /** * When a CommitSSBAction is dispatched @@ -50,7 +50,7 @@ export class ServerSyncBufferEffects { * When the list of actions is not empty, also dispatch an EmptySSBAction * When the list is empty dispatch a NO_ACTION placeholder action */ - @Effect() commitServerSyncBuffer = this.actions$ + commitServerSyncBuffer = createEffect(() => this.actions$ .pipe( ofType(ServerSyncBufferActionTypes.COMMIT), switchMap((action: CommitSSBAction) => { @@ -86,7 +86,7 @@ export class ServerSyncBufferEffects { }) ); }) - ); + )); /** * private method to create an ApplyPatchObjectCacheAction based on a cache entry diff --git a/src/app/core/cache/typed-object.model.ts b/src/app/core/cache/typed-object.model.ts new file mode 100644 index 0000000000..02a530941a --- /dev/null +++ b/src/app/core/cache/typed-object.model.ts @@ -0,0 +1,6 @@ +import { ResourceType } from '../shared/resource-type'; + +export abstract class TypedObject { + static type: ResourceType; + type: ResourceType; +} diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 1eca35d223..be354ddc6f 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -3,11 +3,12 @@ import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; -import { FindListOptions, GetRequest } from '../data/request.models'; +import { GetRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { FindListOptions } from '../data/find-list-options.model'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index ddf909b5b0..3bc87c8de0 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Observable } from 'rxjs'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -6,7 +7,6 @@ import { ConfigObject } from './models/config.model'; import { RemoteData } from '../data/remote-data'; import { DataService } from '../data/data.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; @@ -14,6 +14,7 @@ import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { map } from 'rxjs/operators'; +import { CoreState } from '../core-state.model'; class DataServiceImpl extends DataService { constructor( @@ -31,7 +32,6 @@ class DataServiceImpl extends DataService { } } -// tslint:disable-next-line:max-classes-per-file export abstract class ConfigService { /** * A private DataService instance to delegate specific methods to. diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 53250ee045..170aa334ed 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,8 +1,8 @@ import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../../cache/object-cache.reducer'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; export abstract class ConfigObject implements CacheableObject { diff --git a/src/app/core/config/submission-accesses-config.service.ts b/src/app/core/config/submission-accesses-config.service.ts index de9afc66ea..7c2d2046d9 100644 --- a/src/app/core/config/submission-accesses-config.service.ts +++ b/src/app/core/config/submission-accesses-config.service.ts @@ -7,7 +7,6 @@ import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; @@ -16,6 +15,7 @@ import { SubmissionAccessesModel } from './models/config-submission-accesses.mod import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CoreState } from '../core-state.model'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config.service.ts index a5c3f98060..1db5c2fa01 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config.service.ts @@ -5,7 +5,6 @@ import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; @@ -17,6 +16,7 @@ import { SubmissionFormsModel } from './models/config-submission-forms.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CoreState } from '../core-state.model'; @Injectable() @dataService(SUBMISSION_FORMS_TYPE) diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config.service.ts index a9e35a3183..8ad17749bd 100644 --- a/src/app/core/config/submission-uploads-config.service.ts +++ b/src/app/core/config/submission-uploads-config.service.ts @@ -7,7 +7,6 @@ import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; @@ -16,6 +15,7 @@ import { SubmissionUploadsModel } from './models/config-submission-uploads.model import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CoreState } from '../core-state.model'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. diff --git a/src/app/core/core-state.model.ts b/src/app/core/core-state.model.ts new file mode 100644 index 0000000000..b8211fdb55 --- /dev/null +++ b/src/app/core/core-state.model.ts @@ -0,0 +1,30 @@ +import { + BitstreamFormatRegistryState +} from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { ObjectCacheState } from './cache/object-cache.reducer'; +import { ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; +import { ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; +import { HistoryState } from './history/history.reducer'; +import { MetaIndexState } from './index/index.reducer'; +import { AuthState } from './auth/auth.reducer'; +import { JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; +import { MetaTagState } from './metadata/meta-tag.reducer'; +import { RouteState } from './services/route.reducer'; +import { RequestState } from './data/request-state.model'; + +/** + * The core sub-state in the NgRx store + */ +export interface CoreState { + 'bitstreamFormats': BitstreamFormatRegistryState; + 'cache/object': ObjectCacheState; + 'cache/syncbuffer': ServerSyncBufferState; + 'cache/object-updates': ObjectUpdatesState; + 'data/request': RequestState; + 'history': HistoryState; + 'index': MetaIndexState; + 'auth': AuthState; + 'json/patch': JsonPatchOperationsState; + 'metaTag': MetaTagState; + 'route': RouteState; +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8d8a614a89..b16930e819 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -38,7 +38,7 @@ import { SubmissionSectionModel } from './config/models/config-submission-sectio import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { coreEffects } from './core.effects'; -import { coreReducers, CoreState } from './core.reducers'; +import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; @@ -75,7 +75,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { FeedbackDataService } from './feedback/feedback-data.service'; -import { ApiService } from './services/api.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -133,10 +132,15 @@ import { Feature } from './shared/feature.model'; import { Authorization } from './shared/authorization.model'; import { FeatureDataService } from './data/feature-authorization/feature-data.service'; import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { SiteAdministratorGuard } from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + SiteAdministratorGuard +} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { + DsDynamicTypeBindRelationService +} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; @@ -160,8 +164,20 @@ 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 { CoreState } from './core-state.model'; import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; +import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; +import { AccessStatusDataService } from './data/access-status-data.service'; +import { LinkHeadService } from './services/link-head.service'; +import { ResearcherProfileService } from './profile/researcher-profile.service'; +import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; +import { ResearcherProfile } from './profile/model/researcher-profile.model'; +import { OrcidQueueService } from './orcid/orcid-queue.service'; +import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; +import { OrcidQueue } from './orcid/model/orcid-queue.model'; +import { OrcidHistory } from './orcid/model/orcid-history.model'; +import { OrcidAuthService } from './orcid/orcid-auth.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -186,7 +202,6 @@ const DECLARATIONS = []; const EXPORTS = []; const PROVIDERS = [ - ApiService, AuthenticatedGuard, CommunityDataService, CollectionDataService, @@ -201,6 +216,7 @@ const PROVIDERS = [ SectionFormOperationsService, FormService, EPersonDataService, + LinkHeadService, HALEndpointService, HostWindowService, ItemDataService, @@ -219,6 +235,7 @@ const PROVIDERS = [ MyDSpaceResponseParsingService, ServerResponseService, BrowseService, + AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, SubmissionFormsConfigService, @@ -249,6 +266,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + DsDynamicTypeBindRelationService, EntityTypeService, ContentSourceResponseParsingService, ItemTemplateDataService, @@ -286,6 +304,11 @@ const PROVIDERS = [ SequenceService, GroupDataService, FeedbackDataService, + ResearcherProfileService, + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -345,7 +368,12 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + AccessStatusObject, + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 8b3ec32b46..c0165c5384 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,33 +1,19 @@ import { ActionReducerMap, } from '@ngrx/store'; -import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; -import { indexReducer, MetaIndexState } from './index/index.reducer'; -import { requestReducer, RequestState } from './data/request.reducer'; -import { authReducer, AuthState } from './auth/auth.reducer'; -import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; -import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; -import { objectUpdatesReducer, ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; -import { routeReducer, RouteState } from './services/route.reducer'; +import { objectCacheReducer } from './cache/object-cache.reducer'; +import { indexReducer } from './index/index.reducer'; +import { requestReducer } from './data/request.reducer'; +import { authReducer } from './auth/auth.reducer'; +import { jsonPatchOperationsReducer } from './json-patch/json-patch-operations.reducer'; +import { serverSyncBufferReducer } from './cache/server-sync-buffer.reducer'; +import { objectUpdatesReducer } from './data/object-updates/object-updates.reducer'; +import { routeReducer } from './services/route.reducer'; import { - bitstreamFormatReducer, - BitstreamFormatRegistryState + bitstreamFormatReducer } from '../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; -import { historyReducer, HistoryState } from './history/history.reducer'; -import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer'; - -export interface CoreState { - 'bitstreamFormats': BitstreamFormatRegistryState; - 'cache/object': ObjectCacheState; - 'cache/syncbuffer': ServerSyncBufferState; - 'cache/object-updates': ObjectUpdatesState; - 'data/request': RequestState; - 'history': HistoryState; - 'index': MetaIndexState; - 'auth': AuthState; - 'json/patch': JsonPatchOperationsState; - 'metaTag': MetaTagState; - 'route': RouteState; -} +import { historyReducer } from './history/history.reducer'; +import { metaTagReducer } from './metadata/meta-tag.reducer'; +import { CoreState } from './core-state.model'; export const coreReducers: ActionReducerMap = { 'bitstreamFormats': bitstreamFormatReducer, diff --git a/src/app/core/core.selectors.ts b/src/app/core/core.selectors.ts index 60365be7c2..77c7974de2 100644 --- a/src/app/core/core.selectors.ts +++ b/src/app/core/core.selectors.ts @@ -1,5 +1,5 @@ import { createFeatureSelector } from '@ngrx/store'; -import { CoreState } from './core.reducers'; +import { CoreState } from './core-state.model'; /** * Base selector to select the core state from the store diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts new file mode 100644 index 0000000000..d81b9384f3 --- /dev/null +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { GetRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { hasNoValue } from '../../shared/empty.util'; +import { AccessStatusDataService } from './access-status-data.service'; +import { Item } from '../shared/item.model'; + +const url = 'fake-url'; + +describe('AccessStatusDataService', () => { + let service: AccessStatusDataService; + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + const itemId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const mockItem: Item = Object.assign(new Item(), { + id: itemId, + name: 'test-item', + _links: { + accessStatus: { + href: `https://rest.api/items/${itemId}/accessStatus` + }, + self: { + href: `https://rest.api/items/${itemId}` + } + } + }); + + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); + }); + + describe('when calling findAccessStatusFor', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.findAccessStatusFor(mockItem); + }); + + it('should send a new GetRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), true); + })); + }); + }); + + /** + * Create an AccessStatusDataService used for testing + * @param reponse$ Supply a RemoteData to be returned by the REST API (optional) + */ + function createService(reponse$?: Observable>) { + requestService = getMockRequestService(); + let buildResponse$ = reponse$; + if (hasNoValue(reponse$)) { + buildResponse$ = createSuccessfulRemoteDataObject$({}); + } + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: buildResponse$, + buildSingle: buildResponse$ + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); + } +}); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts new file mode 100644 index 0000000000..09843fac9b --- /dev/null +++ b/src/app/core/data/access-status-data.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { CoreState } from '../core-state.model'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { Item } from '../shared/item.model'; + +@Injectable() +@dataService(ACCESS_STATUS) +export class AccessStatusDataService extends DataService { + + protected linkPath = 'accessStatus'; + + constructor( + protected comparator: DefaultChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected objectCache: ObjectCacheService, + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected store: Store, + ) { + super(); + } + + /** + * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item + * @param item Item we want the access status of + */ + findAccessStatusFor(item: Item): Observable> { + return this.findByHref(item._links.accessStatus.href); + } +} diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts index 94285d49d8..da9fa7a643 100644 --- a/src/app/core/data/base-response-parsing.service.spec.ts +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -1,10 +1,11 @@ +/* eslint-disable max-classes-per-file */ import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { GetRequest, RestRequest } from './request.models'; +import { GetRequest} from './request.models'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ class TestService extends BaseResponseParsingService { toCache = true; @@ -101,4 +102,3 @@ describe('BaseResponseParsingService', () => { }); }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index b571b29f02..18e6623683 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,16 +1,16 @@ +/* eslint-disable max-classes-per-file */ import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { Serializer } from '../serializer'; import { PageInfo } from '../shared/page-info.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList, buildPaginatedList } from './paginated-list.model'; import { getClassForType } from '../cache/builders/build-decorators'; -import { RestRequest } from './request.models'; import { environment } from '../../../environments/environment'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ /** * Return true if halObj has a value for `_links.self` @@ -180,4 +180,3 @@ export abstract class BaseResponseParsingService { return statusCode >= 200 && statusCode < 300; } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 23aec80ff2..16f2cc16c2 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -9,7 +9,6 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Bitstream } from '../shared/bitstream.model'; import { BITSTREAM } from '../shared/bitstream.resource-type'; import { Bundle } from '../shared/bundle.model'; @@ -20,15 +19,17 @@ import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions, PutRequest } from './request.models'; +import { PutRequest } from './request.models'; import { RequestService } from './request.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { sendRequest } from '../shared/operators'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PageInfo } from '../shared/page-info.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { sendRequest } from '../shared/request.operators'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; /** * A service to retrieve {@link Bitstream}s from the REST API diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index c072803c83..30ef79ee6d 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -1,5 +1,4 @@ import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { RequestEntry } from './request.reducer'; import { RestResponse } from '../cache/response.models'; import { Observable, of as observableOf } from 'rxjs'; import { Action, Store } from '@ngrx/store'; @@ -17,8 +16,9 @@ import { BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { TestScheduler } from 'rxjs/testing'; -import { CoreState } from '../core.reducers'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { CoreState } from '../core-state.model'; +import { RequestEntry } from './request-entry.model'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -37,7 +37,12 @@ describe('BitstreamFormatDataService', () => { } } as Store; - const objectCache = {} as ObjectCacheService; + const requestUUIDs = ['some', 'uuid']; + + const objectCache = jasmine.createSpyObj('objectCache', { + getByHref: observableOf({ requestUUIDs }) + }) as ObjectCacheService; + const halEndpointService = { getEndpoint(linkPath: string): Observable { return cold('a', { a: bitstreamFormatsEndpoint }); @@ -76,6 +81,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -96,6 +102,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -118,6 +125,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -139,6 +147,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -163,6 +172,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -186,6 +196,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -209,6 +220,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -231,6 +243,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -253,6 +266,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: cold('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); @@ -273,6 +287,7 @@ describe('BitstreamFormatDataService', () => { send: {}, getByHref: observableOf(responseCacheEntry), getByUUID: hot('a', { a: responseCacheEntry }), + setStaleByUUID: observableOf(true), generateRequestId: 'request-id', removeByHrefSubstring: {} }); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 0d0dc5eb63..1af3db8103 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -13,18 +13,18 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { sendRequest } from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RemoteData } from './remote-data'; import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; +import { sendRequest } from '../shared/request.operators'; +import { CoreState } from '../core-state.model'; const bitstreamFormatsStateSelector = createSelector( coreSelector, diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index ed149a624f..12eec9e33d 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -3,7 +3,6 @@ import { Store } from '@ngrx/store'; import { compare, Operation } from 'fast-json-patch'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { ChangeAnalyzer } from './change-analyzer'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; @@ -13,6 +12,7 @@ import { HALLink } from '../shared/hal-link.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { Bundle } from '../shared/bundle.model'; +import { CoreState } from '../core-state.model'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 3c885c0afd..fa5ee51b45 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -9,7 +9,6 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Bundle } from '../shared/bundle.model'; import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -18,11 +17,13 @@ import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions, GetRequest } from './request.models'; +import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; -import { RequestEntryState } from './request.reducer'; +import { RequestEntryState } from './request-entry-state.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; /** * A service to retrieve {@link Bundle}s from the REST API diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts index 8efe26314e..45fd9b7e84 100644 --- a/src/app/core/data/change-analyzer.ts +++ b/src/app/core/data/change-analyzer.ts @@ -1,6 +1,6 @@ import { Operation } from 'fast-json-patch'; -import { TypedObject } from '../cache/object-cache.reducer'; +import { TypedObject } from '../cache/typed-object.model'; /** * An interface to determine what differs between two diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 470c036df2..c243b49d3f 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -13,7 +13,6 @@ import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { Collection } from '../shared/collection.model'; @@ -27,9 +26,15 @@ import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models'; +import { + ContentSourceRequest, + UpdateContentSourceRequest +} from './request.models'; import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; +import { RestRequest } from './rest-request.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; @Injectable() @dataService(COLLECTION) @@ -282,4 +287,12 @@ export class CollectionDataService extends ComColDataService { return this.findAllByHref(item._links.mappedCollections.href, findListOptions); } + + protected getScopeCommunityHref(options: FindListOptions) { + return this.cds.getEndpoint().pipe( + map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), + filter((href: string) => isNotEmpty(href)), + take(1) + ); + } } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 864c583dc2..dffc97f294 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -7,13 +7,11 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { FindListOptions } from './request.models'; import { RequestService } from './request.service'; import { createFailedRemoteDataObject$, @@ -22,9 +20,18 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { BitstreamDataService } from './bitstream-data.service'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; +import { Bitstream } from '../shared/bitstream.model'; const LINK_NAME = 'test'; +const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; + +const communitiesEndpoint = 'https://rest.api/core/communities'; + +const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; + class TestService extends ComColDataService { constructor( @@ -47,9 +54,14 @@ class TestService extends ComColDataService { // implementation in subclasses for communities/collections return undefined; } + + protected getScopeCommunityHref(options: FindListOptions): Observable { + // implementation in subclasses for communities/collections + return observableOf(communityEndpoint); + } } -// tslint:disable:no-shadowed-variable +/* eslint-disable @typescript-eslint/no-shadow */ describe('ComColDataService', () => { let service: TestService; let requestService: RequestService; @@ -66,12 +78,9 @@ describe('ComColDataService', () => { const http = {} as HttpClient; const comparator = {} as any; - const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const options = Object.assign(new FindListOptions(), { scopeID: scopeID }); - const communitiesEndpoint = 'https://rest.api/core/communities'; - const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; const mockHalService = { @@ -236,4 +245,75 @@ describe('ComColDataService', () => { }); }); }); + + describe('deleteLogo', () => { + let dso; + + beforeEach(() => { + dso = { + _links: { + logo: { + href: 'logo-href' + } + } + }; + }); + + describe('when DSO has no logo', () => { + beforeEach(() => { + dso.logo = undefined; + }); + + it('should return a failed RD', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasFailed).toBeTrue(); + expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('when DSO has a logo', () => { + let logo; + + beforeEach(() => { + logo = Object.assign(new Bitstream, { + id: 'logo-id', + _links: { + self: { + href: 'logo-href', + } + } + }); + }); + + describe('that can be retrieved', () => { + beforeEach(() => { + dso.logo = createSuccessfulRemoteDataObject$(logo); + }); + + it('should call BitstreamDataService.deleteByHref', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasSucceeded).toBeTrue(); + expect(bitstreamDataService.deleteByHref).toHaveBeenCalledWith('logo-href'); + done(); + }); + }); + }); + + describe('that cannot be retrieved', () => { + beforeEach(() => { + dso.logo = createFailedRemoteDataObject$(logo); + }); + + it('should not call BitstreamDataService.deleteByHref', (done) => { + service.deleteLogo(dso).subscribe(rd => { + expect(rd.hasFailed).toBeTrue(); + expect(bitstreamDataService.deleteByHref).not.toHaveBeenCalled(); + done(); + }); + }); + }); + }); + }); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 12aedf8009..01cd18df0c 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -4,10 +4,7 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { HALLink } from '../shared/hal-link.model'; -import { CommunityDataService } from './community-data.service'; - import { DataService } from './data.service'; -import { FindListOptions } from './request.models'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -19,9 +16,9 @@ import { NoContent } from '../shared/NoContent.model'; import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; import { URLCombiner } from '../url-combiner/url-combiner'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from './find-list-options.model'; export abstract class ComColDataService extends DataService { - protected abstract cds: CommunityDataService; protected abstract objectCache: ObjectCacheService; protected abstract halService: HALEndpointService; protected abstract bitstreamDataService: BitstreamDataService; @@ -40,11 +37,7 @@ export abstract class ComColDataService extend if (isEmpty(options.scopeID)) { return this.halService.getEndpoint(linkPath); } else { - const scopeCommunityHrefObs = this.cds.getEndpoint().pipe( - map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), - filter((href: string) => isNotEmpty(href)), - take(1) - ); + const scopeCommunityHrefObs = this.getScopeCommunityHref(options); this.createAndSendGetRequest(scopeCommunityHrefObs, true); @@ -65,6 +58,8 @@ export abstract class ComColDataService extend } } + protected abstract getScopeCommunityHref(options: FindListOptions): Observable; + protected abstract getFindByParentHref(parentUUID: string): Observable; public findByParent(parentUUID: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 8dee72e391..903d9bc79c 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -3,12 +3,11 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -16,17 +15,18 @@ import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { FindListOptions } from './request.models'; import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; @Injectable() @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'search/top'; - protected cds = this; constructor( protected requestService: RequestService, @@ -58,4 +58,11 @@ export class CommunityDataService extends ComColDataService { ); } + protected getScopeCommunityHref(options: FindListOptions) { + return this.getEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, options.scopeID)), + filter((href: string) => isNotEmpty(href)), + take(1) + ); + } } diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index 91d5af6ecc..c8241aa9c7 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; @@ -6,7 +7,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; import { RemoteData } from './remote-data'; @@ -14,8 +14,8 @@ import { RequestService } from './request.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; +import { CoreState } from '../core-state.model'; -/* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { protected linkPath = 'properties'; @@ -60,4 +60,3 @@ export class ConfigurationDataService { return this.dataService.findById(name); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 42b8f85c42..066ccf28c9 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -4,8 +4,8 @@ import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { ContentSource } from '../shared/content-source.model'; import { MetadataConfig } from '../shared/metadata-config.model'; -import { RestRequest } from './request.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() /** diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 5bc7423824..dc661e12d7 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { compare, Operation } from 'fast-json-patch'; @@ -7,14 +8,17 @@ import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; import { ChangeAnalyzer } from './change-analyzer'; import { DataService } from './data.service'; -import { FindListOptions, PatchRequest } from './request.models'; +import { PatchRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; @@ -22,11 +26,15 @@ import { RequestParam } from '../cache/models/request-param.model'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { TestScheduler } from 'rxjs/testing'; import { RemoteData } from './remote-data'; -import { RequestEntryState } from './request.reducer'; +import { RequestEntryState } from './request-entry-state.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; +import { fakeAsync, tick } from '@angular/core/testing'; const endpoint = 'https://rest.api/core'; -/* tslint:disable:max-classes-per-file */ +const BOOLEAN = { f: false, t: true }; + class TestService extends DataService { constructor( @@ -85,6 +93,9 @@ describe('DataService', () => { }, getObjectBySelfLink: () => { /* empty */ + }, + getByHref: () => { + /* empty */ } } as any; store = {} as Store; @@ -832,5 +843,169 @@ describe('DataService', () => { }); }); + + describe('invalidateByHref', () => { + let getByHrefSpy: jasmine.Spy; + + beforeEach(() => { + getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2', 'request3'] + })); + + }); + + it('should call setStaleByUUID for every request associated with this DSO', (done) => { + service.invalidateByHref('some-href').subscribe((ok) => { + expect(ok).toBeTrue(); + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + done(); + }); + }); + + it('should call setStaleByUUID even if not subscribing to returned Observable', fakeAsync(() => { + service.invalidateByHref('some-href'); + tick(); + + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + })); + + it('should return an Observable that only emits true once all requests are stale', () => { + testScheduler.run(({ cold, expectObservable }) => { + requestService.setStaleByUUID.and.callFake((uuid) => { + switch (uuid) { // fake requests becoming stale at different times + case 'request1': + return cold('--(t|)', BOOLEAN); + case 'request2': + return cold('----(t|)', BOOLEAN); + case 'request3': + return cold('------(t|)', BOOLEAN); + } + }); + + const done$ = service.invalidateByHref('some-href'); + + // emit true as soon as the final request is stale + expectObservable(done$).toBe('------(t|)', BOOLEAN); + }); + }); + + it('should only fire for the current state of the object (instead of tracking it)', () => { + testScheduler.run(({ cold, flush }) => { + getByHrefSpy.and.returnValue(cold('a---b---c---', { + a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache + b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state + c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't + })); + + service.invalidateByHref('some-href'); + flush(); + + // requests from the first state are marked as stale + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + + // request from subsequent states are ignored + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request3'); + }); + }); + }); + + describe('delete', () => { + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let invalidateByHrefSpy: jasmine.Spy; + let buildFromRequestUUIDSpy: jasmine.Spy; + let getIDHrefObsSpy: jasmine.Spy; + let deleteByHrefSpy: jasmine.Spy; + + beforeEach(() => { + invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); + getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough(); + deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + it('should retrieve href by ID and call deleteByHref', () => { + getIDHrefObsSpy.and.returnValue(observableOf('some-href')); + buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); + + service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => { + expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id'); + expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']); + }); + }); + + describe('deleteByHref', () => { + it('should call invalidateByHref if the DELETE request succeeds', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_SUCCEEDED_RD); + expect(invalidateByHrefSpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href'); + tick(); + + expect(invalidateByHrefSpy).toHaveBeenCalled(); + })); + + it('should not call invalidateByHref if the DELETE request fails', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_FAILED_RD); + expect(invalidateByHrefSpy).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should wait for invalidateByHref before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away + ); + invalidateByHrefSpy.and.returnValue( + cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer + ); + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done + ); + }); + }); + + it('should wait for the DELETE request to resolve before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while + ); + invalidateByHrefSpy.and.returnValue( + cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner + ); // e.g.: maybe already stale before this call? + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request + ); + }); + }); + }); + }); }); -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 6bad02e776..6176694d9d 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,18 +1,19 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; +import { AsyncSubject, combineLatest, from as observableFrom, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, find, map, mergeMap, + skipWhile, + switchMap, take, takeWhile, - switchMap, tap, - skipWhile, + toArray } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; @@ -21,30 +22,25 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getClassForType } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getRemoteDataPayload, getFirstSucceededRemoteData, } from '../shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { ChangeAnalyzer } from './change-analyzer'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { - CreateRequest, - GetRequest, - FindListOptions, - PatchRequest, - PutRequest, - DeleteRequest -} from './request.models'; +import { CreateRequest, DeleteRequest, GetRequest, PatchRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; import { UpdateDataService } from './update-data.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { NoContent } from '../shared/NoContent.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; export abstract class DataService implements UpdateDataService { protected abstract requestService: RequestService; @@ -168,7 +164,7 @@ export abstract class DataService implements UpdateDa * @return {Observable} * Return an observable that emits created HREF */ - protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { + buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { let args = []; if (hasValue(params)) { @@ -579,6 +575,39 @@ export abstract class DataService implements UpdateDa return result$; } + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param objectId The id of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidate(objectId: string): Observable { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.invalidateByHref(href)) + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + take(1), + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } + /** * Delete an existing DSpace Object on the server * @param objectId The id of the object to be removed @@ -600,6 +629,7 @@ export abstract class DataService implements UpdateDa * metadata should be saved as real metadata * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. */ deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { const requestId = this.requestService.generateRequestId(); @@ -618,7 +648,27 @@ export abstract class DataService implements UpdateDa } this.requestService.send(request); - return this.rdbService.buildFromRequestUUID(requestId); + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return this.invalidateByHref(href); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return combineLatest([response$, invalidated$]).pipe( + filter(([_, invalidated]) => invalidated), + map(([response, _]) => response), + ); } /** diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index fbc07cbb39..992a29e4b8 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { RestResponse } from '../cache/response.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { RestRequest } from './rest-request.model'; @Injectable() export class DebugResponseParsingService implements ResponseParsingService { diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 34a619648a..70c45bbc2d 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -2,9 +2,9 @@ import { Injectable } from '@angular/core'; import { compare } from 'fast-json-patch'; import { Operation } from 'fast-json-patch'; import { getClassForType } from '../cache/builders/build-decorators'; -import { TypedObject } from '../cache/object-cache.reducer'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; +import { DSpaceNotNullSerializer } from '../dspace-rest/dspace-not-null.serializer'; import { ChangeAnalyzer } from './change-analyzer'; +import { TypedObject } from '../cache/typed-object.model'; /** * A class to determine what differs between two @@ -22,8 +22,8 @@ export class DefaultChangeAnalyzer implements ChangeAnaly * The second object to compare */ diff(object1: T, object2: T): Operation[] { - const serializer1 = new DSpaceSerializer(getClassForType(object1.type)); - const serializer2 = new DSpaceSerializer(getClassForType(object2.type)); + const serializer1 = new DSpaceNotNullSerializer(getClassForType(object1.type)); + const serializer2 = new DSpaceNotNullSerializer(getClassForType(object2.type)); return compare(serializer1.serialize(object1), serializer2.serialize(object2)); } } diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts index bcd25487c2..3f3a799e45 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect-data.service.spec.ts @@ -6,13 +6,13 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DsoRedirectDataService } from './dso-redirect-data.service'; import { GetRequest, IdentifierType } from './request.models'; import { RequestService } from './request.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; +import { CoreState } from '../core-state.model'; describe('DsoRedirectDataService', () => { let scheduler: TestScheduler; diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts index 83395d4719..6270689f03 100644 --- a/src/app/core/data/dso-redirect-data.service.ts +++ b/src/app/core/data/dso-redirect-data.service.ts @@ -9,7 +9,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @@ -20,6 +19,7 @@ import { getFirstCompletedRemoteData } from '../shared/operators'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { CoreState } from '../core-state.model'; @Injectable() export class DsoRedirectDataService extends DataService { diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 7dde1f53a1..fd5a22fae9 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -3,12 +3,12 @@ import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; -import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { RestRequest } from './rest-request.model'; @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index eb230e2f54..ae0d525281 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; @@ -7,7 +8,6 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -15,10 +15,10 @@ import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { FindListOptions } from './request.models'; import { PaginatedList } from './paginated-list.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; -/* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { protected linkPath = 'dso'; @@ -104,4 +104,3 @@ export class DSpaceObjectDataService { } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 2fda0bf40a..500afc4aff 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -1,13 +1,12 @@ +/* eslint-disable max-classes-per-file */ import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { Serializer } from '../serializer'; import { PageInfo } from '../shared/page-info.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList, buildPaginatedList } from './paginated-list.model'; import { getClassForType } from '../cache/builders/build-decorators'; -import { RestRequest } from './request.models'; import { environment } from '../../../environments/environment'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -17,8 +16,9 @@ import { ParsedResponse } from '../cache/response.models'; import { RestRequestMethod } from './rest-request-method'; import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; -/* tslint:disable:max-classes-per-file */ /** * Return true if obj has a value for `_links.self` @@ -271,4 +271,3 @@ export class DspaceRestResponseParsingService implements ResponseParsingService return statusCode >= 200 && statusCode < 300; } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index 1a81deaea0..728714876c 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -7,12 +7,12 @@ import { import { hasValue } from '../../shared/empty.util'; import { getClassForType } from '../cache/builders/build-decorators'; import { GenericConstructor } from '../shared/generic-constructor'; -import { RestRequest } from './request.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { ParsedResponse } from '../cache/response.models'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { environment } from '../../../environments/environment'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { RestRequest } from './rest-request.model'; /** * ResponseParsingService able to deal with HAL Endpoints that are only needed as steps diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index 40b9373107..d08e6d28e7 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -3,14 +3,12 @@ import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; -import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { RemoteData } from './remote-data'; @@ -19,6 +17,8 @@ import { PaginatedList } from './paginated-list.model'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { RelationshipTypeService } from './relationship-type.service'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; /** * Service handling all ItemType requests diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index 2407249615..dc13fff3a0 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -1,7 +1,6 @@ import { RequestService } from './request.service'; import { EpersonRegistrationService } from './eperson-registration.service'; import { RestResponse } from '../cache/response.models'; -import { RequestEntry } from './request.reducer'; import { cold } from 'jasmine-marbles'; import { PostRequest } from './request.models'; import { Registration } from '../shared/registration.model'; @@ -9,6 +8,7 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; +import { RequestEntry } from './request-entry.model'; describe('EpersonRegistrationService', () => { let testScheduler; @@ -90,15 +90,17 @@ describe('EpersonRegistrationService', () => { const expected = service.searchByToken('test-token'); expect(expected).toBeObservable(cold('(a|)', { - a: Object.assign(new Registration(), { - email: registrationWithUser.email, - token: 'test-token', - user: registrationWithUser.user + a: jasmine.objectContaining({ + payload: Object.assign(new Registration(), { + email: registrationWithUser.email, + token: 'test-token', + user: registrationWithUser.user + }) }) })); }); - // tslint:disable:no-shadowed-variable + /* eslint-disable @typescript-eslint/no-shadow */ it('should use cached responses and /registrations/search/findByToken?', () => { testScheduler.run(({ cold, expectObservable }) => { rdbService.buildSingle.and.returnValue(cold('a', { a: rd })); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index adf01b0ce9..989a401733 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -79,7 +79,7 @@ export class EpersonRegistrationService { * Search a registration based on the provided token * @param token */ - searchByToken(token: string): Observable { + searchByToken(token: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getTokenSearchEndpoint(token).pipe( @@ -97,15 +97,14 @@ export class EpersonRegistrationService { }); return this.rdbService.buildSingle(href$).pipe( - skipWhile((rd: RemoteData) => rd.isStale), - getFirstSucceededRemoteData(), - map((restResponse: RemoteData) => { - return Object.assign(new Registration(), { - email: restResponse.payload.email, token: token, user: restResponse.payload.user - }); - }), + map((rd) => { + if (rd.hasSucceeded && hasValue(rd.payload)) { + return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) }); + } else { + return rd; + } + }) ); - } } diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index d2fc9e6d96..6ea7e07d28 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -4,12 +4,10 @@ import { ExternalSource } from '../shared/external-source.model'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { FindListOptions } from './request.models'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -19,6 +17,8 @@ import { PaginatedList } from './paginated-list.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; /** * A service handling all external source requests 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 8c24bd61d9..3e4493c32b 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -3,9 +3,9 @@ import { SearchFilterConfig } from '../../shared/search/models/search-filter-con 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/models/facet-config-response.model'; +import { RestRequest } from './rest-request.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 12a2d4ba8c..0911ed5073 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -3,9 +3,9 @@ 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/models/facet-values.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() export class FacetValueResponseParsingService extends DspaceRestResponseParsingService { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index 01bd23d7c7..df46d3f0a1 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -4,7 +4,6 @@ import { AuthService } from '../../auth/auth.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { of as observableOf } from 'rxjs'; -import { FindListOptions } from '../request.models'; import { FeatureID } from './feature-id'; import { hasValue } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; @@ -12,6 +11,7 @@ import { Authorization } from '../../shared/authorization.model'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; import { Feature } from '../../shared/feature.model'; +import { FindListOptions } from '../find-list-options.model'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index b9812cdbb3..f27919844d 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -7,7 +7,6 @@ import { Authorization } from '../../shared/authorization.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -15,7 +14,6 @@ import { HttpClient } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; import { AuthService } from '../../auth/auth.service'; import { SiteDataService } from '../site-data.service'; -import { FindListOptions } from '../request.models'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteData } from '../remote-data'; import { PaginatedList } from '../paginated-list.model'; @@ -26,6 +24,8 @@ import { AuthorizationSearchParams } from './authorization-search-params'; import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { CoreState } from '../../core-state.model'; +import { FindListOptions } from '../find-list-options.model'; /** * A service to retrieve {@link Authorization}s from the REST API @@ -60,14 +60,18 @@ export class AuthorizationDataService extends DataService { /** * Checks if an {@link EPerson} (or anonymous) has access to a specific object within a {@link Feature} - * @param objectUrl URL to the object to search {@link Authorization}s for. - * If not provided, the repository's {@link Site} will be used. - * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. - * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. - * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @param objectUrl URL to the object to search {@link Authorization}s for. + * If not provided, the repository's {@link Site} will be used. + * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. + * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. + * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @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 */ - isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable { - return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, true, true, followLink('feature')).pipe( + isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, useCachedVersionIfAvailable, reRequestOnStale, followLink('feature')).pipe( getFirstCompletedRemoteData(), map((authorizationRD) => { if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) { diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts index 3a6cf745c9..b909640ea6 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts @@ -2,9 +2,9 @@ import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTr import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/operators'; import { switchMap } from 'rxjs/operators'; import { AuthService } from '../../../auth/auth.service'; +import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authorized.operators'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -19,7 +19,7 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate { /** * True when user has authorization rights for the feature and object provided - * Redirect the user to the unauthorized page when he/she's not authorized for the given feature + * Redirect the user to the unauthorized page when they are not authorized for the given feature */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index 12be6f8452..cbe8356660 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -6,12 +6,12 @@ import { Feature } from '../../shared/feature.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { CoreState } from '../../core-state.model'; /** * A service to retrieve {@link Feature}s from the REST API diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 029c75d9cb..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -28,4 +28,6 @@ export enum FeatureID { CanCreateVersion = 'canCreateVersion', CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', + CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } diff --git a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts index 7a2ff7962d..da7a21c488 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { FilteredDiscoveryQueryResponse, RestResponse } from '../cache/response.models'; +import { RestRequest } from './rest-request.model'; /** * A ResponseParsingService used to parse RawRestResponse coming from the REST API to a discovery query (string) @@ -16,7 +16,8 @@ export class FilteredDiscoveryPageResponseParsingService extends BaseResponsePar toCache = false; constructor( protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(); } /** diff --git a/src/app/core/data/find-list-options.model.ts b/src/app/core/data/find-list-options.model.ts new file mode 100644 index 0000000000..52a527d9e0 --- /dev/null +++ b/src/app/core/data/find-list-options.model.ts @@ -0,0 +1,14 @@ +import { SortOptions } from '../cache/models/sort-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; + +/** + * The options for a find list request + */ +export class FindListOptions { + scopeID?: string; + elementsPerPage?: number; + currentPage?: number; + sort?: SortOptions; + searchParams?: RequestParam[]; + startsWith?: string; +} diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index dd4be83203..64c451837d 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,8 @@ import { HrefOnlyDataService } from './href-only-data.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FindListOptions } from './request.models'; import { DataService } from './data.service'; +import { FindListOptions } from './find-list-options.model'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index b1bc14ec6f..60c225cb34 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,8 +1,8 @@ +/* eslint-disable max-classes-per-file */ import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -10,7 +10,6 @@ import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; -import { FindListOptions } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteData } from './remote-data'; @@ -18,9 +17,10 @@ import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list.model'; import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; import { LICENSE } from '../shared/license.resource-type'; -import { CacheableObject } from '../cache/object-cache.reducer'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; -/* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { // linkPath isn't used if we're only searching by href. protected linkPath = undefined; diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 26a6b52cc3..a4ed9f882f 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -8,13 +8,15 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { BrowseService } from '../browse/browse.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, FindListOptions, PostRequest } from './request.models'; -import { RequestEntry } from './request.reducer'; +import { DeleteRequest, GetRequest, PostRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { CoreState } from '../core-state.model'; +import { RequestEntry } from './request-entry.model'; +import { FindListOptions } from './find-list-options.model'; +import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -35,13 +37,11 @@ describe('ItemDataService', () => { }) as RequestService; const rdbService = getMockRemoteDataBuildService(); - const itemEndpoint = 'https://rest.api/core/items'; + const itemEndpoint = 'https://rest.api/core'; const store = {} as Store; const objectCache = {} as ObjectCacheService; - const halEndpointService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf(itemEndpoint) - }); + const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { findByHref: {} }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index a8d380124e..cb5d7a3d57 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -9,21 +9,19 @@ import { BrowseService } from '../browse/browse.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { Collection } from '../shared/collection.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; -import { sendRequest } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models'; +import { DeleteRequest, GetRequest, PostRequest, PutRequest} from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bundle } from '../shared/bundle.model'; @@ -34,6 +32,10 @@ import { NoContent } from '../shared/NoContent.model'; import { GenericConstructor } from '../shared/generic-constructor'; import { ResponseParsingService } from './parsing.service'; import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; +import { sendRequest } from '../shared/request.operators'; +import { RestRequest } from './rest-request.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; @Injectable() @dataService(ITEM) diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 41ad19211a..2bab0b304f 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -3,7 +3,7 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteData } from './remote-data'; import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; @@ -11,13 +11,14 @@ import { ItemRequest } from '../shared/item-request.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { DataService } from './data.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { CoreState } from '../core-state.model'; +import { sendRequest } from '../shared/request.operators'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 1458527506..4b8aa362ba 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -1,12 +1,9 @@ import { ItemTemplateDataService } from './item-template-data.service'; -import { RestRequest } from './request.models'; -import { RequestEntry } from './request.reducer'; import { RestResponse } from '../cache/response.models'; import { RequestService } from './request.service'; import { Observable, of as observableOf } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -15,6 +12,9 @@ import { HttpClient } from '@angular/common/http'; import { CollectionDataService } from './collection-data.service'; import { RestRequestMethod } from './rest-request-method'; import { Item } from '../shared/item.model'; +import { RestRequest } from './rest-request.model'; +import { CoreState } from '../core-state.model'; +import { RequestEntry } from './request-entry.model'; describe('ItemTemplateDataService', () => { let service: ItemTemplateDataService; diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index 19e6941385..fd9f7de031 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { ItemDataService } from './item-data.service'; import { UpdateDataService } from './update-data.service'; @@ -9,7 +10,6 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -23,8 +23,8 @@ import { NoContent } from '../shared/NoContent.model'; import { hasValue } from '../../shared/empty.util'; import { Operation } from 'fast-json-patch'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { CoreState } from '../core-state.model'; -/* tslint:disable:max-classes-per-file */ /** * A custom implementation of the ItemDataService, but for collection item templates * Makes sure to change the endpoint before sending out CRUD requests for the item template @@ -228,4 +228,3 @@ export class ItemTemplateDataService implements UpdateDataService { return this.dataService.getCollectionEndpoint(collectionID); } } -/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index bb621f74b3..54a174e365 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -4,12 +4,12 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { of as observableOf } from 'rxjs'; import { RestResponse } from '../cache/response.models'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { FindListOptions } from './request.models'; import { MetadataFieldDataService } from './metadata-field-data.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RequestParam } from '../cache/models/request-param.model'; +import { FindListOptions } from './find-list-options.model'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 3b11859361..5a78213c84 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -7,7 +7,6 @@ import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; @@ -16,11 +15,12 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { FindListOptions } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { RequestParam } from '../cache/models/request-param.model'; +import { CoreState } from '../core-state.model'; +import { FindListOptions } from './find-list-options.model'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index ff1796313e..f277f6cab6 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -5,7 +5,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -16,6 +15,7 @@ import { Observable } from 'rxjs'; import { hasValue } from '../../shared/empty.util'; import { tap } from 'rxjs/operators'; import { RemoteData } from './remote-data'; +import { CoreState } from '../core-state.model'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index e111aca9dd..e46e319149 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@angular/core'; import { ParsedResponse } from '../cache/response.models'; 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/models/search-objects.model'; import { MetadataMap, MetadataValue } from '../shared/metadata.models'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { RestRequest } from './rest-request.model'; @Injectable() export class MyDSpaceResponseParsingService extends DspaceRestResponseParsingService { diff --git a/src/app/core/data/object-updates/field-change-type.model.ts b/src/app/core/data/object-updates/field-change-type.model.ts new file mode 100644 index 0000000000..7d8e308945 --- /dev/null +++ b/src/app/core/data/object-updates/field-change-type.model.ts @@ -0,0 +1,8 @@ +/** + * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store + */ +export enum FieldChangeType { + UPDATE = 0, + ADD = 1, + REMOVE = 2 +} diff --git a/src/app/core/data/object-updates/field-update.model.ts b/src/app/core/data/object-updates/field-update.model.ts new file mode 100644 index 0000000000..47b6782471 --- /dev/null +++ b/src/app/core/data/object-updates/field-update.model.ts @@ -0,0 +1,10 @@ +import { Identifiable } from './identifiable.model'; +import { FieldChangeType } from './field-change-type.model'; + +/** + * The state of a single field update + */ +export interface FieldUpdate { + field: Identifiable; + changeType: FieldChangeType; +} diff --git a/src/app/core/data/object-updates/field-updates.model.ts b/src/app/core/data/object-updates/field-updates.model.ts new file mode 100644 index 0000000000..eff804bd02 --- /dev/null +++ b/src/app/core/data/object-updates/field-updates.model.ts @@ -0,0 +1,8 @@ +import { FieldUpdate } from './field-update.model'; + +/** + * The states of all field updates available for a single page, mapped by uuid + */ +export interface FieldUpdates { + [uuid: string]: FieldUpdate; +} diff --git a/src/app/core/data/object-updates/identifiable.model.ts b/src/app/core/data/object-updates/identifiable.model.ts new file mode 100644 index 0000000000..7d32859338 --- /dev/null +++ b/src/app/core/data/object-updates/identifiable.model.ts @@ -0,0 +1,6 @@ +/** + * Represents every object that has a UUID + */ +export interface Identifiable { + uuid: string; +} diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 13bbabb286..615dedbaf9 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,9 +1,11 @@ -import {type} from '../../../shared/ngrx/type'; -import {Action} from '@ngrx/store'; -import {Identifiable} from './object-updates.reducer'; -import {INotification} from '../../../shared/notifications/models/notification.model'; +/* eslint-disable max-classes-per-file */ +import { type } from '../../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { INotification } from '../../../shared/notifications/models/notification.model'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { GenericConstructor } from '../../shared/generic-constructor'; +import { Identifiable } from './identifiable.model'; +import { FieldChangeType } from './field-change-type.model'; /** * The list of ObjectUpdatesAction type definitions @@ -21,16 +23,6 @@ export const ObjectUpdatesActionTypes = { REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') }; -/* tslint:disable:max-classes-per-file */ - -/** - * Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store - */ -export enum FieldChangeType { - UPDATE = 0, - ADD = 1, - REMOVE = 2 -} /** * An ngrx action to initialize a new page's fields in the ObjectUpdates state @@ -283,7 +275,6 @@ export class RemoveFieldUpdateAction implements Action { } } -/* tslint:enable:max-classes-per-file */ /** * A type to encompass all ObjectUpdatesActions diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index c9c3237ef5..1dfdc95f23 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, @@ -52,7 +52,7 @@ export class ObjectUpdatesEffects { /** * Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key */ - @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + mapLastActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { @@ -64,12 +64,12 @@ export class ObjectUpdatesEffects { this.actionMap$[url].next(action); } }) - ); + ), { dispatch: false }); /** * Effect that makes sure all last fired NotificationActions are stored in the notification map of this service, with the id as their key */ - @Effect({ dispatch: false }) mapLastNotificationActions$ = this.actions$ + mapLastNotificationActions$ = createEffect(() => this.actions$ .pipe( ofType(...Object.values(NotificationsActionTypes)), map((action: RemoveNotificationAction) => { @@ -80,7 +80,7 @@ export class ObjectUpdatesEffects { this.notificationActionMap$[id].next(action); } ) - ); + ), { dispatch: false }); /** * Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction @@ -88,7 +88,7 @@ export class ObjectUpdatesEffects { * When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned * When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned */ - @Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$ + removeAfterDiscardOrReinstateOnUndo$ = createEffect(() => this.actions$ .pipe( ofType(ObjectUpdatesActionTypes.DISCARD), switchMap((action: DiscardObjectUpdatesAction) => { @@ -134,7 +134,7 @@ export class ObjectUpdatesEffects { ); } ) - ); + )); constructor(private actions$: Actions, private notificationsService: NotificationsService) { diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 4d7fce24a7..a51a1431bb 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -2,7 +2,6 @@ import * as deepFreeze from 'deep-freeze'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, @@ -14,6 +13,7 @@ import { } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; import { Relationship } from '../../shared/item-relationships/relationship.model'; +import { FieldChangeType } from './field-change-type.model'; class NullAction extends RemoveFieldUpdateAction { type = null; diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 6dfb4ab584..14bacc52db 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,16 +1,15 @@ import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction, - SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { Relationship } from '../../shared/item-relationships/relationship.model'; @@ -18,6 +17,9 @@ import { PatchOperationService } from './patch-operation-service/patch-operation import { Item } from '../../shared/item.model'; import { RelationshipType } from '../../shared/item-relationships/relationship-type.model'; import { GenericConstructor } from '../../shared/generic-constructor'; +import { Identifiable } from './identifiable.model'; +import { FieldUpdates } from './field-updates.model'; +import { FieldChangeType } from './field-change-type.model'; /** * Path where discarded objects are saved @@ -40,28 +42,6 @@ export interface FieldStates { [uuid: string]: FieldState; } -/** - * Represents every object that has a UUID - */ -export interface Identifiable { - uuid: string; -} - -/** - * The state of a single field update - */ -export interface FieldUpdate { - field: Identifiable; - changeType: FieldChangeType; -} - -/** - * The states of all field updates available for a single page, mapped by uuid - */ -export interface FieldUpdates { - [uuid: string]: FieldUpdate; -} - /** * The states of all virtual metadata selections available for a single page, mapped by the relationship uuid */ diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 6c0b0f99c4..9cf856f03a 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -1,9 +1,7 @@ import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, @@ -16,6 +14,8 @@ import { NotificationType } from '../../../shared/notifications/models/notificat import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import { Relationship } from '../../shared/item-relationships/relationship.model'; import { Injector } from '@angular/core'; +import { FieldChangeType } from './field-change-type.model'; +import { CoreState } from '../../core-state.model'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 88c7c0e453..2fb6d47d31 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,11 +1,8 @@ import { Injectable, Injector } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { coreSelector } from '../../core.selectors'; import { FieldState, - FieldUpdates, - Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, ObjectUpdatesState, @@ -15,7 +12,6 @@ import { Observable } from 'rxjs'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, - FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, @@ -35,6 +31,10 @@ import { INotification } from '../../../shared/notifications/models/notification import { Operation } from 'fast-json-patch'; import { PatchOperationService } from './patch-operation-service/patch-operation.service'; import { GenericConstructor } from '../../shared/generic-constructor'; +import { Identifiable } from './identifiable.model'; +import { FieldUpdates } from './field-updates.model'; +import { FieldChangeType } from './field-change-type.model'; +import { CoreState } from '../../core-state.model'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts index 9708266cd7..db46426b79 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -1,8 +1,8 @@ import { MetadataPatchOperationService } from './metadata-patch-operation.service'; -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; -import { FieldChangeType } from '../object-updates.actions'; import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldUpdates } from '../field-updates.model'; +import { FieldChangeType } from '../field-change-type.model'; describe('MetadataPatchOperationService', () => { let service: MetadataPatchOperationService; diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts index 59c981872a..33e9129a9d 100644 --- a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -1,14 +1,14 @@ import { PatchOperationService } from './patch-operation.service'; import { MetadatumViewModel } from '../../../shared/metadata.models'; -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; -import { FieldChangeType } from '../object-updates.actions'; import { Injectable } from '@angular/core'; import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; import { hasValue } from '../../../../shared/empty.util'; import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; +import { FieldUpdates } from '../field-updates.model'; +import { FieldChangeType } from '../field-change-type.model'; /** * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts index 7c67f9a2e5..171c1d2a54 100644 --- a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -1,5 +1,5 @@ -import { FieldUpdates } from '../object-updates.reducer'; import { Operation } from 'fast-json-patch'; +import { FieldUpdates } from '../field-updates.model'; /** * Interface for a service dealing with the transformations of patch operations from the object-updates store diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index e85a91f791..415bfe234e 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -3,11 +3,11 @@ import { hasValue, isEmpty, hasNoValue, isUndefined } from '../../shared/empty.u import { HALResource } from '../shared/hal-resource.model'; import { HALLink } from '../shared/hal-link.model'; import { typedObject } from '../cache/builders/build-decorators'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { PAGINATED_LIST } from './paginated-list.resource-type'; import { ResourceType } from '../shared/resource-type'; import { excludeFromEquals } from '../utilities/equals.decorators'; import { autoserialize, deserialize } from 'cerialize'; +import { CacheableObject } from '../cache/cacheable-object.model'; /** * Factory function for a paginated list diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index bebbd63fd7..fbebe75b2b 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,6 +1,6 @@ import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; -import { RestRequest } from './request.models'; import { ParsedResponse } from '../cache/response.models'; +import { RestRequest } from './rest-request.model'; export interface ResponseParsingService { parse(request: RestRequest, data: RawRestResponse): ParsedResponse; diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index cadcdb3bfe..cb00ac529e 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -3,7 +3,6 @@ import { DataService } from '../data.service'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -18,6 +17,7 @@ import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; import { RemoteData } from '../remote-data'; import { BitstreamDataService } from '../bitstream-data.service'; +import { CoreState } from '../../core-state.model'; @Injectable() @dataService(PROCESS) @@ -38,7 +38,7 @@ export class ProcessDataService extends DataService { } /** - * Get the endpoint for a process his files + * Get the endpoint for the files of the process * @param processId The ID of the process */ getFilesEndpoint(processId: string): Observable { diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 69b4270173..75a66c822a 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'; import { DataService } from '../data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -13,12 +12,16 @@ import { ProcessParameter } from '../../../process-page/processes/process-parame import { map, take } from 'rxjs/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; import { RemoteData } from '../remote-data'; -import { MultipartPostRequest, RestRequest } from '../request.models'; +import { MultipartPostRequest} from '../request.models'; import { RequestService } from '../request.service'; import { Observable } from 'rxjs'; import { dataService } from '../../cache/builders/build-decorators'; import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; import { Process } from '../../../process-page/processes/process.model'; +import { hasValue } from '../../../shared/empty.util'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { RestRequest } from '../rest-request.model'; +import { CoreState } from '../../core-state.model'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; @@ -62,4 +65,16 @@ export class ScriptDataService extends DataService + diff --git a/src/main.browser.ts b/src/main.browser.ts index de4276ea93..d5efe828c3 100644 --- a/src/main.browser.ts +++ b/src/main.browser.ts @@ -1,4 +1,4 @@ -import 'zone.js/dist/zone'; +import 'zone.js'; import 'reflect-metadata'; import 'core-js/es/reflect'; @@ -15,9 +15,7 @@ import { AppConfig } from './config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './config/config.util'; const bootstrap = () => platformBrowserDynamic() - .bootstrapModule(BrowserAppModule, { - preserveWhitespaces: true - }); + .bootstrapModule(BrowserAppModule, {}); const main = () => { // Load fonts async @@ -30,14 +28,14 @@ const main = () => { if (environment.production) { enableProdMode(); + } + if (hasValue(environment.universal) && environment.universal.preboot) { return bootstrap(); } else { - return fetch('assets/config.json') .then((response) => response.json()) .then((appConfig: AppConfig) => { - // extend environment with app config for browser when not prerendered extendEnvironmentWithAppConfig(environment, appConfig); @@ -47,7 +45,7 @@ const main = () => { }; // support async tag or hmr -if (hasValue(environment.universal) && environment.universal.preboot === false) { +if (document.readyState === 'complete' && hasValue(environment.universal) && !environment.universal.preboot) { main(); } else { document.addEventListener('DOMContentLoaded', main); diff --git a/src/main.server.ts b/src/main.server.ts index 83ca0192a0..91425136f8 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -1,5 +1,5 @@ import 'core-js/es/reflect'; -import 'zone.js/dist/zone'; +import 'zone.js'; import 'reflect-metadata'; import { enableProdMode } from '@angular/core'; diff --git a/src/mirador-viewer/mirador.html b/src/mirador-viewer/mirador.html index 6a9547133c..3cd1e16501 100644 --- a/src/mirador-viewer/mirador.html +++ b/src/mirador-viewer/mirador.html @@ -6,5 +6,6 @@
+ diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 88a59eb157..404c624a8a 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,49 +1,37 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; +import { BrowserModule, BrowserTransferStateModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterModule, NoPreloading } from '@angular/router'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TranslateJson5HttpLoader } from '../../ngx-translate-loaders/translate-json5-http.loader'; +import { TranslateBrowserLoader } from '../../ngx-translate-loaders/translate-browser.loader'; import { IdlePreloadModule } from 'angular-idle-preload'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceBrowserTransferStateModule } from '../transfer-state/dspace-browser-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { ClientCookieService } from '../../app/core/services/client-cookie.service'; import { CookieService } from '../../app/core/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; -import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; +import { Angulartics2RouterlessModule } from 'angulartics2'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; import { KlaroService } from '../../app/shared/cookies/klaro.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; -import { - BrowserHardRedirectService, - locationProvider, - LocationToken -} from '../../app/core/services/browser-hard-redirect.service'; +import { BrowserHardRedirectService, locationProvider, LocationToken } from '../../app/core/services/browser-hard-redirect.service'; import { LocaleService } from '../../app/core/locale/locale.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; -import { DefaultAppConfig } from '../../config/default-app-config'; -import { extendEnvironmentWithAppConfig } from '../../config/config.util'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; - -import { environment } from '../../environments/environment'; +import { BrowserInitService } from './browser-init.service'; export const REQ_KEY = makeStateKey('req'); -export function createTranslateLoader(http: HttpClient) { - return new TranslateJson5HttpLoader(http, 'assets/i18n/', '.json5'); +export function createTranslateLoader(transferState: TransferState, http: HttpClient) { + return new TranslateBrowserLoader(transferState, http, 'assets/i18n/', '.json5'); } export function getRequest(transferState: TransferState): any { @@ -59,46 +47,21 @@ export function getRequest(transferState: TransferState): any { HttpClientModule, // forRoot ensures the providers are only created once IdlePreloadModule.forRoot(), - RouterModule.forRoot([], { - // enableTracing: true, - useHash: false, - scrollPositionRestoration: 'enabled', - anchorScrolling: 'enabled', - preloadingStrategy: NoPreloading - }), StatisticsModule.forRoot(), Angulartics2RouterlessModule.forRoot(), BrowserAnimationsModule, - DSpaceBrowserTransferStateModule, + BrowserTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [HttpClient] + deps: [TransferState, HttpClient] } }), AppModule ], providers: [ - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService - ) => { - if (transferState.hasKey(APP_CONFIG_STATE)) { - const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); - // extend environment with app config for browser - extendEnvironmentWithAppConfig(environment, appConfig); - } - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...BrowserInitService.providers(), { provide: REQUEST, useFactory: getRequest, diff --git a/src/modules/app/browser-init.service.spec.ts b/src/modules/app/browser-init.service.spec.ts new file mode 100644 index 0000000000..05da7d9d36 --- /dev/null +++ b/src/modules/app/browser-init.service.spec.ts @@ -0,0 +1,155 @@ +import { InitService } from '../../app/init.service'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { CommonModule } from '@angular/common'; +import { Store, StoreModule } from '@ngrx/store'; +import { authReducer } from '../../app/core/auth/auth.reducer'; +import { storeModuleConfig } from '../../app/app.reducer'; +import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { AuthServiceMock } from '../../app/shared/mocks/auth.service.mock'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterMock } from '../../app/shared/mocks/router.mock'; +import { MockActivatedRoute } from '../../app/shared/mocks/active-router.mock'; +import { MenuService } from '../../app/shared/menu/menu.service'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { environment } from '../../environments/environment'; +import { provideMockStore } from '@ngrx/store/testing'; +import { AppComponent } from '../../app/app.component'; +import { RouteService } from '../../app/core/services/route.service'; +import { getMockLocaleService } from '../../app/app.component.spec'; +import { MenuServiceStub } from '../../app/shared/testing/menu-service.stub'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../app/shared/mocks/translate-loader.mock'; +import { getTestScheduler } from 'jasmine-marbles'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { getMockThemeService } from '../../app/shared/mocks/theme-service.mock'; +import { BrowserInitService } from './browser-init.service'; +import { TransferState } from '@angular/platform-browser'; + +const initialState = { + core: { + auth: { + loading: false, + blocking: true, + } + } +}; + +describe('BrowserInitService', () => { + describe('browser-specific initialization steps', () => { + let correlationIdServiceSpy; + let dspaceTransferStateSpy; + let transferStateSpy; + let metadataServiceSpy; + let breadcrumbsServiceSpy; + let klaroServiceSpy; + let googleAnalyticsSpy; + + beforeEach(waitForAsync(() => { + correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [ + 'initCorrelationId', + ]); + dspaceTransferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'transfer', + ]); + transferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'get', 'hasKey' + ]); + breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [ + 'listenForRouteChanges', + ]); + metadataServiceSpy = jasmine.createSpyObj('metadataService', [ + 'listenForRouteChange', + ]); + klaroServiceSpy = jasmine.createSpyObj('klaroServiceSpy', [ + 'initialize', + ]); + googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [ + 'addTrackingIdToPage', + ]); + + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot(authReducer, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: InitService, useClass: BrowserInitService }, + { provide: CorrelationIdService, useValue: correlationIdServiceSpy }, + { provide: APP_CONFIG, useValue: environment }, + { provide: LocaleService, useValue: getMockLocaleService() }, + { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, + { provide: MetadataService, useValue: metadataServiceSpy }, + { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: MenuService, useValue: new MenuServiceStub() }, + { provide: KlaroService, useValue: klaroServiceSpy }, + { provide: GoogleAnalyticsService, useValue: googleAnalyticsSpy }, + { provide: ThemeService, useValue: getMockThemeService() }, + provideMockStore({ initialState }), + AppComponent, + RouteService, + { provide: TransferState, useValue: undefined }, + ] + }); + })); + + describe('initGoogleÀnalytics', () => { + it('should call googleAnalyticsService.addTrackingIdToPage()', inject([InitService], (service) => { + // @ts-ignore + service.initGoogleAnalytics(); + expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1); + })); + }); + + describe('initKlaro', () => { + const BLOCKING = { + t: { core: { auth: { blocking: true } } }, + f: { core: { auth: { blocking: false } } }, + }; + + it('should not initialize Klaro while auth is blocking', () => { + getTestScheduler().run(({ cold, flush}) => { + TestBed.overrideProvider(Store, { useValue: cold('t--t--t--', BLOCKING) }); + const service = TestBed.inject(InitService); + + // @ts-ignore + service.initKlaro(); + flush(); + expect(klaroServiceSpy.initialize).not.toHaveBeenCalled(); + }); + }); + + + it('should only initialize Klaro the first time auth is unblocked', () => { + getTestScheduler().run(({ cold, flush}) => { + TestBed.overrideProvider(Store, { useValue: cold('t--t--f--t--f--', BLOCKING) }); + const service = TestBed.inject(InitService); + + // @ts-ignore + service.initKlaro(); + flush(); + expect(klaroServiceSpy.initialize).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); + diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts new file mode 100644 index 0000000000..80619800fb --- /dev/null +++ b/src/modules/app/browser-init.service.ts @@ -0,0 +1,136 @@ +/** + * 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/ + */ +import { InitService } from '../../app/init.service'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { DefaultAppConfig } from '../../config/default-app-config'; +import { extendEnvironmentWithAppConfig } from '../../config/config.util'; +import { environment } from '../../environments/environment'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { StoreAction, StoreActionTypes } from '../../app/store.actions'; +import { coreSelector } from '../../app/core/core.selectors'; +import { distinctUntilChanged, filter, find, map, take } from 'rxjs/operators'; +import { isNotEmpty } from '../../app/shared/empty.util'; +import { isAuthenticationBlocking } from '../../app/core/auth/selectors'; + +/** + * Performs client-side initialization. + */ +@Injectable() +export class BrowserInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected googleAnalyticsService: GoogleAnalyticsService, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected klaroService: KlaroService, + protected authService: AuthService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected static resolveAppConfig( + transferState: TransferState, + ) { + if (transferState.hasKey(APP_CONFIG_STATE)) { + const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); + // extend environment with app config for browser + extendEnvironmentWithAppConfig(environment, appConfig); + } + } + + protected init(): () => Promise { + return async () => { + await this.loadAppState(); + this.checkAuthenticationToken(); + this.initCorrelationId(); + + this.checkEnvironment(); + + this.initI18n(); + this.initAngulartics(); + this.initGoogleAnalytics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(true); + this.trackAuthTokenExpiration(); + + this.initKlaro(); + + return true; + }; + } + + // Browser-only initialization steps + + /** + * Retrieve server-side application state from the {@link NGRX_STATE} key and rehydrate the store. + * Resolves once the store is no longer empty. + * @private + */ + private async loadAppState(): Promise { + const state = this.transferState.get(InitService.NGRX_STATE, null); + this.transferState.remove(InitService.NGRX_STATE); + this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); + return this.store.select(coreSelector).pipe( + find((core: any) => isNotEmpty(core)), + map(() => true) + ).toPromise(); + } + + private trackAuthTokenExpiration(): void { + this.authService.trackTokenExpiration(); + } + + /** + * Initialize Klaro + * @protected + */ + protected initKlaro() { + this.store.pipe( + select(isAuthenticationBlocking), + distinctUntilChanged(), + filter((isBlocking: boolean) => isBlocking === false), + take(1) + ).subscribe(() => { + this.klaroService.initialize(); + }); + } + + protected initGoogleAnalytics() { + this.googleAnalyticsService.addTrackingIdToPage(); + } +} diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 01a5548948..35fa050d6f 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -1,21 +1,18 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { BrowserModule, TransferState } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ServerModule } from '@angular/platform-server'; -import { RouterModule } from '@angular/router'; +import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Angulartics2 } from 'angulartics2'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; -import { TranslateJson5UniversalLoader } from '../../ngx-translate-loaders/translate-json5-universal.loader'; +import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader'; import { CookieService } from '../../app/core/services/cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; @@ -32,13 +29,10 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; +import { ServerInitService } from './server-init.service'; -import { environment } from '../../environments/environment'; - -export function createTranslateLoader() { - return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); +export function createTranslateLoader(transferState: TransferState) { + return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5'); } @NgModule({ @@ -47,38 +41,20 @@ export function createTranslateLoader() { BrowserModule.withServerTransition({ appId: 'dspace-angular' }), - RouterModule.forRoot([], { - useHash: false - }), NoopAnimationsModule, - DSpaceServerTransferStateModule, + ServerTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [] + deps: [TransferState] } }), + AppModule, ServerModule, - AppModule ], providers: [ - // Initialize app config and extend environment - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService, - ) => { - transferState.set(APP_CONFIG_STATE, environment as AppConfig); - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...ServerInitService.providers(), { provide: Angulartics2, useClass: Angulartics2Mock diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts new file mode 100644 index 0000000000..803dc7a75a --- /dev/null +++ b/src/modules/app/server-init.service.ts @@ -0,0 +1,93 @@ +/** + * 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/ + */ +import { InitService } from '../../app/init.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { environment } from '../../environments/environment'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { CSSVariableService } from '../../app/shared/sass-helper/sass-helper.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { take } from 'rxjs/operators'; + +/** + * Performs server-side initialization. + */ +@Injectable() +export class ServerInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected cssService: CSSVariableService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected init(): () => Promise { + return async () => { + this.checkAuthenticationToken(); + this.saveAppConfigForCSR(); + this.saveAppState(); + this.initCorrelationId(); + + this.checkEnvironment(); + this.initI18n(); + this.initAngulartics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(false); + + return true; + }; + } + + // Server-only initialization steps + + /** + * Set the {@link NGRX_STATE} key when state is serialized to be transfered + * @private + */ + private saveAppState() { + this.transferState.onSerialize(InitService.NGRX_STATE, () => { + let state; + this.store.pipe(take(1)).subscribe((saveState: any) => { + state = saveState; + }); + + return state; + }); + } + + private saveAppConfigForCSR(): void { + this.transferState.set(APP_CONFIG_STATE, environment as AppConfig); + } +} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts b/src/modules/transfer-state/dspace-browser-transfer-state.module.ts deleted file mode 100644 index e251d0b3b2..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { BrowserTransferStateModule } from '@angular/platform-browser'; -import { DSpaceBrowserTransferState } from './dspace-browser-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - BrowserTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceBrowserTransferState } - ] -}) -export class DSpaceBrowserTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts b/src/modules/transfer-state/dspace-browser-transfer-state.service.ts deleted file mode 100644 index ae3306c3fb..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@angular/core'; -import { StoreAction, StoreActionTypes } from '../../app/store.actions'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@Injectable() -export class DSpaceBrowserTransferState extends DSpaceTransferState { - transfer() { - const state = this.transferState.get(DSpaceTransferState.NGRX_STATE, null); - this.transferState.remove(DSpaceTransferState.NGRX_STATE); - this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); - } -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.module.ts b/src/modules/transfer-state/dspace-server-transfer-state.module.ts deleted file mode 100644 index f8f2631cd0..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ServerTransferStateModule } from '@angular/platform-server'; -import { DSpaceServerTransferState } from './dspace-server-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - ServerTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceServerTransferState } - ] -}) -export class DSpaceServerTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.service.ts b/src/modules/transfer-state/dspace-server-transfer-state.service.ts deleted file mode 100644 index ac8c817d84..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import {take} from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@Injectable() -export class DSpaceServerTransferState extends DSpaceTransferState { - transfer() { - this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => { - let state; - this.store.pipe(take(1)).subscribe((saveState: any) => { - state = saveState; - }); - - return state; - }); - } -} diff --git a/src/modules/transfer-state/dspace-transfer-state.service.ts b/src/modules/transfer-state/dspace-transfer-state.service.ts deleted file mode 100644 index 05b1109f17..0000000000 --- a/src/modules/transfer-state/dspace-transfer-state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core'; -import { makeStateKey, TransferState } from '@angular/platform-browser'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../app/app.reducer'; - -@Injectable() -export abstract class DSpaceTransferState { - - protected static NGRX_STATE = makeStateKey('NGRX_STATE'); - - constructor( - protected transferState: TransferState, - protected store: Store - ) { - } - - abstract transfer(): void; -} diff --git a/src/ngx-translate-loaders/ngx-translate-state.ts b/src/ngx-translate-loaders/ngx-translate-state.ts new file mode 100644 index 0000000000..4e6c2f496b --- /dev/null +++ b/src/ngx-translate-loaders/ngx-translate-state.ts @@ -0,0 +1,15 @@ +import { makeStateKey } from '@angular/platform-browser'; + +/** + * Represents ngx-translate messages in different languages in the TransferState + */ +export class NgxTranslateState { + [lang: string]: { + [key: string]: string + } +} + +/** + * The key to store the NgxTranslateState as part of the TransferState + */ +export const NGX_TRANSLATE_STATE = makeStateKey('NGX_TRANSLATE_STATE'); diff --git a/src/ngx-translate-loaders/translate-browser.loader.ts b/src/ngx-translate-loaders/translate-browser.loader.ts new file mode 100644 index 0000000000..217f301bd5 --- /dev/null +++ b/src/ngx-translate-loaders/translate-browser.loader.ts @@ -0,0 +1,44 @@ +import { TranslateLoader } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { TransferState } from '@angular/platform-browser'; +import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state'; +import { hasValue } from '../app/shared/empty.util'; +import { map } from 'rxjs/operators'; +import { of as observableOf, Observable } from 'rxjs'; +import * as JSON5 from 'json5'; + +/** + * A TranslateLoader for ngx-translate to retrieve i18n messages from the TransferState, or download + * them if they're not available there + */ +export class TranslateBrowserLoader implements TranslateLoader { + constructor( + protected transferState: TransferState, + protected http: HttpClient, + protected prefix?: string, + protected suffix?: string + ) { + } + + /** + * Return the i18n messages for a given language, first try to find them in the TransferState + * retrieve them using HttpClient if they're not available there + * + * @param lang the language code + */ + getTranslation(lang: string): Observable { + // Get the ngx-translate messages from the transfer state, to speed up the initial page load + // client side + const state = this.transferState.get(NGX_TRANSLATE_STATE, {}); + const messages = state[lang]; + if (hasValue(messages)) { + return observableOf(messages); + } else { + // If they're not available on the transfer state (e.g. when running in dev mode), retrieve + // them using HttpClient + return this.http.get('' + this.prefix + lang + this.suffix, { responseType: 'text' }).pipe( + map((json: any) => JSON5.parse(json)) + ); + } + } +} diff --git a/src/ngx-translate-loaders/translate-json5-http.loader.ts b/src/ngx-translate-loaders/translate-json5-http.loader.ts deleted file mode 100644 index b6759408ce..0000000000 --- a/src/ngx-translate-loaders/translate-json5-http.loader.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { TranslateLoader } from '@ngx-translate/core'; -import { map } from 'rxjs/operators'; -import * as JSON5 from 'json5'; - -export class TranslateJson5HttpLoader implements TranslateLoader { - constructor(private http: HttpClient, public prefix?: string, public suffix?: string) { - } - - getTranslation(lang: string): any { - return this.http.get('' + this.prefix + lang + this.suffix, {responseType: 'text'}).pipe( - map((json: any) => JSON5.parse(json)) - ); - } -} diff --git a/src/ngx-translate-loaders/translate-json5-universal.loader.ts b/src/ngx-translate-loaders/translate-json5-universal.loader.ts deleted file mode 100644 index c557fb9a3e..0000000000 --- a/src/ngx-translate-loaders/translate-json5-universal.loader.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TranslateLoader } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import * as JSON5 from 'json5' -import * as fs from 'fs'; - -export class TranslateJson5UniversalLoader implements TranslateLoader { - - constructor(private prefix: string = 'dist/assets/i18n/', private suffix: string = '.json') { } - - public getTranslation(lang: string): Observable { - return Observable.create((observer: any) => { - observer.next(JSON5.parse(fs.readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8'))); - observer.complete(); - }); - } - -} diff --git a/src/ngx-translate-loaders/translate-server.loader.ts b/src/ngx-translate-loaders/translate-server.loader.ts new file mode 100644 index 0000000000..4ba6ccd5e9 --- /dev/null +++ b/src/ngx-translate-loaders/translate-server.loader.ts @@ -0,0 +1,52 @@ +import { TranslateLoader } from '@ngx-translate/core'; +import { Observable, of as observableOf } from 'rxjs'; +import * as fs from 'fs'; +import { TransferState } from '@angular/platform-browser'; +import { NGX_TRANSLATE_STATE, NgxTranslateState } from './ngx-translate-state'; + +const JSON5 = require('json5').default; + +/** + * A TranslateLoader for ngx-translate to parse json5 files server-side, and store them in the + * TransferState + */ +export class TranslateServerLoader implements TranslateLoader { + + constructor( + protected transferState: TransferState, + protected prefix: string = 'dist/assets/i18n/', + protected suffix: string = '.json' + ) { + } + + /** + * Return the i18n messages for a given language, and store them in the TransferState + * + * @param lang the language code + */ + public getTranslation(lang: string): Observable { + // Retrieve the file for the given language, and parse it + const messages = JSON5.parse(fs.readFileSync(`${this.prefix}${lang}${this.suffix}`, 'utf8')); + // Store the parsed messages in the transfer state so they'll be available immediately when the + // app loads on the client + this.storeInTransferState(lang, messages); + // Return the parsed messages to translate things server side + return observableOf(messages); + } + + /** + * Store the i18n messages for the given language code in the transfer state, so they can be + * retrieved client side + * + * @param lang the language code + * @param messages the i18n messages + * @protected + */ + protected storeInTransferState(lang: string, messages) { + const prevState = this.transferState.get(NGX_TRANSLATE_STATE, {}); + const nextState = Object.assign({}, prevState, { + [lang]: messages + }); + this.transferState.set(NGX_TRANSLATE_STATE, nextState); + } +} diff --git a/src/polyfills.ts b/src/polyfills.ts index 92b55f1ac5..e8ab71da80 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -18,16 +18,6 @@ * BROWSER POLYFILLS */ -/** IE10 and IE11 requires the following for NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** - * Web Animations `@angular/platform-browser/animations` - * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. - * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). - */ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - /** * By default, zone.js will patch all possible macroTask and DomEvents * user can disable parts of macroTask/DomEvents patch by setting following flags @@ -59,7 +49,7 @@ import 'core-js/es'; import 'core-js/features/reflect'; -import 'zone.js/dist/zone'; // Included with Angular CLI. +import 'zone.js'; // Included with Angular CLI. import 'reflect-metadata'; diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 4c631a294a..3b06efb9d5 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -6,10 +6,15 @@ $sidebar-items-width: 250px !default; $total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default; /* Fonts */ -$fa-font-path: "/assets/fonts" !default; +// Starting this url with a caret (^) allows it to be a relative path based on UI's deployment path +// See https://github.com/angular/angular-cli/issues/12797#issuecomment-598534241 +$fa-font-path: "^assets/fonts" !default; /* Images */ $image-path: "../assets/images" !default; +// enable-responsive-font-sizes allows text to scale more naturally across device and viewport sizes +$enable-responsive-font-sizes: true; + /** Bootstrap Variables **/ /* Colors */ $gray-700: #495057 !default; // Bootstrap $gray-700 diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 66e0e87f93..40180d8342 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -85,4 +85,6 @@ --ds-slider-handle-width: 18px; --ds-search-form-scope-max-width: 150px; + + --ds-gap: 0.25rem; } diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index e337539c15..89d1d76e9a 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -92,3 +92,49 @@ ngb-modal-backdrop { hyphens: auto; } + +.researcher-profile-switch button:focus{ + outline: none !important; +} +.researcher-profile-switch .switch.checked{ + color: #fff; +} + +/* Replicate default spacing look ~ preserveWhitespace=true + To be used e.g. on a div containing buttons that should have a bit of spacing in between + */ +.space-children-mr > :not(:last-child) { + margin-right: var(--ds-gap); +} + +/* Complement .space-children-mr when spaced elements are not on the same level */ +.mr-gap { + margin-right: var(--ds-gap); +} + +.ml-gap { + margin-left: var(--ds-gap); +} + +.custom-accordion .card-header button { + -webkit-box-shadow: none!important; + box-shadow: none!important; + width: 100%; +} +.custom-accordion .card:first-of-type { + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; + border-bottom-left-radius: var(--bs-card-border-radius) !important; + border-bottom-right-radius: var(--bs-card-border-radius) !important; +} + +ds-dynamic-form-control-container.d-none { + /* Ensures that form-control containers hidden and disabled by type binding collapse and let other fields in + the same row expand accordingly + */ + visibility: collapse; +} + +/* Used for dso administrative functionality */ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/styles/_truncatable-part.component.scss b/src/styles/_truncatable-part.component.scss index b938f3a199..d9158a6751 100644 --- a/src/styles/_truncatable-part.component.scss +++ b/src/styles/_truncatable-part.component.scss @@ -19,7 +19,7 @@ min-width: 75px; max-width: 150px; height: $height; - background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%); + background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba($bg, 1) 70%); pointer-events: none; } } @@ -61,6 +61,15 @@ $h4-factor: strip-unit($h4-font-size); .ds-hover .clamp-primary-#{$i} { @include clamp-with-titles($i, darken($primary, 10%)); } + + .clamp-light-#{$i} { + @include clamp-with-titles($i, $light); + } + + :focus .clamp-light-#{$i}, + .ds-hover .clamp-light-#{$i} { + @include clamp-with-titles($i, darken($light, 10%)); + } } .clamp-none { diff --git a/src/styles/_vendor.scss b/src/styles/_vendor.scss new file mode 100644 index 0000000000..9d9842b9b3 --- /dev/null +++ b/src/styles/_vendor.scss @@ -0,0 +1,5 @@ +// node_modules imports meant for all the themes + +@import '~node_modules/bootstrap/scss/bootstrap.scss'; +@import '~node_modules/nouislider/distribute/nouislider.min'; +@import '~node_modules/ngx-ui-switch/ui-switch.component.scss'; diff --git a/src/styles/base-theme.scss b/src/styles/base-theme.scss index bde50bcfd7..539f9fe185 100644 --- a/src/styles/base-theme.scss +++ b/src/styles/base-theme.scss @@ -1,6 +1,5 @@ @import './helpers/font_awesome_imports.scss'; -@import '../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../node_modules/nouislider/distribute/nouislider.min'; +@import './_vendor.scss'; @import './_custom_variables.scss'; @import './bootstrap_variables_mapping.scss'; @import './_truncatable-part.component.scss'; diff --git a/src/test.ts b/src/test.ts index 16317897b1..477195418b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,19 +1,30 @@ // This file is required by karma.conf.js and loads recursively all the .spec and framework files -import 'zone.js/dist/zone-testing'; +import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; +import { MockStore } from '@ngrx/store/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; declare const require: any; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, - platformBrowserDynamicTesting() + platformBrowserDynamicTesting(), + { teardown: { destroyAfterEach: false } } ); + +jasmine.getEnv().afterEach(() => { + // If store is mocked, reset state after each test (see https://ngrx.io/guide/migration/v13) + getTestBed().inject(MockStore, null)?.resetSelectors(); + // Close any leftover modals + getTestBed().inject(NgbModal, null)?.dismissAll?.(); +}); + // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.html b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.scss b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts new file mode 100644 index 0000000000..6485ad98e6 --- /dev/null +++ b/src/themes/custom/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { AdminSidebarComponent as BaseComponent } from '../../../../../app/admin/admin-sidebar/admin-sidebar.component'; + +/** + * Component representing the admin sidebar + */ +@Component({ + selector: 'ds-admin-sidebar', + // templateUrl: './admin-sidebar.component.html', + templateUrl: '../../../../../app/admin/admin-sidebar/admin-sidebar.component.html', + // styleUrls: ['./admin-sidebar.component.scss'] + styleUrls: ['../../../../../app/admin/admin-sidebar/admin-sidebar.component.scss'] +}) +export class AdminSidebarComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.scss b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts new file mode 100644 index 0000000000..ad9f515dcf --- /dev/null +++ b/src/themes/custom/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { + EditItemTemplatePageComponent as BaseComponent +} from '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component'; + +@Component({ + selector: 'ds-edit-item-template-page', + styleUrls: ['./edit-item-template-page.component.scss'], + // templateUrl: './edit-item-template-page.component.html', + templateUrl: '../../../../../app/collection-page/edit-item-template-page/edit-item-template-page.component.html', +}) +/** + * Component for editing the item template of a collection + */ +export class EditItemTemplatePageComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.html b/src/themes/custom/app/community-list-page/community-list/community-list.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.scss b/src/themes/custom/app/community-list-page/community-list/community-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/community-list-page/community-list/community-list.component.ts b/src/themes/custom/app/community-list-page/community-list/community-list.component.ts new file mode 100644 index 0000000000..61b538d641 --- /dev/null +++ b/src/themes/custom/app/community-list-page/community-list/community-list.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { CommunityListComponent as BaseComponent } from '../../../../../app/community-list-page/community-list/community-list.component'; + +/** + * A tree-structured list of nodes representing the communities, their subCommunities and collections. + * Initially only the page-restricted top communities are shown. + * Each node can be expanded to show its children and all children are also page-limited. + * More pages of a page-limited result can be shown by pressing a show more node/link. + * Which nodes were expanded is kept in the store, so this persists across pages. + */ +@Component({ + selector: 'ds-community-list', + // styleUrls: ['./community-list.component.scss'], + // templateUrl: './community-list.component.html' + templateUrl: '../../../../../app/community-list-page/community-list/community-list.component.html' +}) +export class CommunityListComponent extends BaseComponent {} + diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts new file mode 100644 index 0000000000..a9f23c25f6 --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalIssueComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('JournalIssue', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal-issue', + // styleUrls: ['./journal-issue.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss'], + // templateUrl: './journal-issue.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Issue + */ +export class JournalIssueComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts new file mode 100644 index 0000000000..1a190dc2e8 --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalVolumeComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('JournalVolume', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal-volume', + // styleUrls: ['./journal-volume.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss'], + // templateUrl: './journal-volume.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal Volume + */ +export class JournalVolumeComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts new file mode 100644 index 0000000000..7b64c1a35d --- /dev/null +++ b/src/themes/custom/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + JournalComponent as BaseComponent +} from '../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component'; +import { Context } from '../../../../../../../app/core/shared/context.model'; + +@listableObjectComponent('Journal', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-journal', + // styleUrls: ['./journal.component.scss'], + styleUrls: ['../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component.scss'], + // templateUrl: './journal.component.html', + templateUrl: '../../../../../../../app/entity-groups/journal-entities/item-pages/journal/journal.component.html', +}) +/** + * The component for displaying metadata and relations of an item of the type Journal + */ +export class JournalComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts b/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts index e3e5ac8d19..50e196bfaf 100644 --- a/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/themes/custom/app/info/end-user-agreement/end-user-agreement.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { EndUserAgreementComponent as BaseComponent } from '../../../../../app/info/end-user-agreement/end-user-agreement.component'; @Component({ - selector: 'ds-home-news', + selector: 'ds-end-user-agreement', // styleUrls: ['./end-user-agreement.component.scss'], styleUrls: ['../../../../../app/info/end-user-agreement/end-user-agreement.component.scss'], // templateUrl: './end-user-agreement.component.html' diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts new file mode 100644 index 0000000000..d6d7c4b8fb --- /dev/null +++ b/src/themes/custom/app/item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { + ItemMetadataComponent as BaseComponent +} from '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component'; + +@Component({ + selector: 'ds-item-metadata', + // styleUrls: ['./item-metadata.component.scss'], + styleUrls: ['../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.scss'], + // templateUrl: './item-metadata.component.html', + templateUrl: '../../../../../../app/item-page/edit-item-page/item-metadata/item-metadata.component.html', +}) +/** + * Component for displaying an item's metadata edit page + */ +export class ItemMetadataComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.scss b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts new file mode 100644 index 0000000000..1d1676e92f --- /dev/null +++ b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Item } from '../../../../../../../app/core/shared/item.model'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { + listableObjectComponent +} from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { + UntypedItemComponent as BaseComponent +} from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; + +/** + * Component that represents an untyped Item page + */ +@listableObjectComponent(Item, ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-untyped-item', + // styleUrls: ['./untyped-item.component.scss'], + styleUrls: ['../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component.scss'], + // templateUrl: './untyped-item.component.html', + templateUrl: '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UntypedItemComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts new file mode 100644 index 0000000000..3e11271bf0 --- /dev/null +++ b/src/themes/custom/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { + ExpandableNavbarSectionComponent as BaseComponent +} from '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component'; +import { slide } from '../../../../../app/shared/animations/slide'; +import { rendersSectionForMenu } from '../../../../../app/shared/menu/menu-section.decorator'; +import { MenuID } from '../../../../../app/shared/menu/menu-id.model'; + +/** + * Represents an expandable section in the navbar + */ +@Component({ + selector: 'ds-expandable-navbar-section', + // templateUrl: './expandable-navbar-section.component.html', + templateUrl: '../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.html', + // styleUrls: ['./expandable-navbar-section.component.scss'], + styleUrls: ['../../../../../app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss'], + animations: [slide] +}) +@rendersSectionForMenu(MenuID.PUBLIC, true) +export class ExpandableNavbarSectionComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/search-page/search-page.component.ts b/src/themes/custom/app/search-page/search-page.component.ts index e8973975b5..44314b7183 100644 --- a/src/themes/custom/app/search-page/search-page.component.ts +++ b/src/themes/custom/app/search-page/search-page.component.ts @@ -1,11 +1,19 @@ import { Component } from '@angular/core'; import { SearchPageComponent as BaseComponent } from '../../../../app/search-page/search-page.component'; +import { SEARCH_CONFIG_SERVICE } from '../../../../app/my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../app/core/shared/search/search-configuration.service'; @Component({ selector: 'ds-search-page', // styleUrls: ['./search-page.component.scss'], // templateUrl: './search-page.component.html' - templateUrl: '../../../../app/search-page/search-page.component.html' + templateUrl: '../../../../app/search-page/search-page.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] }) /** diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts new file mode 100644 index 0000000000..af54aacd44 --- /dev/null +++ b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { + AuthNavMenuComponent as BaseComponent, +} from '../../../../../app/shared/auth-nav-menu/auth-nav-menu.component'; +import { fadeInOut, fadeOut } from '../../../../../app/shared/animations/fade'; + +/** + * Component representing the {@link AuthNavMenuComponent} of a page + */ +@Component({ + selector: 'ds-auth-nav-menu', + // templateUrl: 'auth-nav-menu.component.html', + templateUrl: '../../../../../app/shared/auth-nav-menu/auth-nav-menu.component.html', + // styleUrls: ['auth-nav-menu.component.scss'], + styleUrls: ['../../../../../app/shared/auth-nav-menu/auth-nav-menu.component.scss'], + animations: [fadeInOut, fadeOut] +}) +export class AuthNavMenuComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts new file mode 100644 index 0000000000..22d40ff539 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + CreateCollectionParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; + +@Component({ + selector: 'ds-create-collection-parent-selector', + // styleUrls: ['./create-collection-parent-selector.component.scss'], + // templateUrl: './create-collection-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class CreateCollectionParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html new file mode 100644 index 0000000000..84fdd34c01 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss new file mode 100644 index 0000000000..0daf4cfa5f --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss @@ -0,0 +1,3 @@ +#create-community-or-separator { + top: 0; +} \ No newline at end of file diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts new file mode 100644 index 0000000000..8b28ee1bb8 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { + CreateCommunityParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; + +@Component({ + selector: 'ds-create-community-parent-selector', + // styleUrls: ['./create-community-parent-selector.component.scss'], + styleUrls: ['../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss'], + // templateUrl: './create-community-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html', +}) +export class CreateCommunityParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html new file mode 100644 index 0000000000..664aef95c0 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -0,0 +1,15 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts new file mode 100644 index 0000000000..f8e3401454 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -0,0 +1,13 @@ +import {Component} from '@angular/core'; +import { + CreateItemParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; + +@Component({ + selector: 'ds-create-item-parent-selector', + // styleUrls: ['./create-item-parent-selector.component.scss'], + // templateUrl: './create-item-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html', +}) +export class CreateItemParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts new file mode 100644 index 0000000000..8f4a8dd5cd --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCollectionSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; + +@Component({ + selector: 'ds-edit-collection-selector', + // styleUrls: ['./edit-collection-selector.component.scss'], + // templateUrl: './edit-collection-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCollectionSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts new file mode 100644 index 0000000000..79d52fc350 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCommunitySelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-community-selector.component.scss'], + // templateUrl: './edit-community-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCommunitySelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts new file mode 100644 index 0000000000..398dbc933c --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditItemSelectorComponent as BaseComponent +} from 'src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-item-selector.component.scss'], + // templateUrl: './edit-item-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditItemSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/loading/loading.component.html b/src/themes/custom/app/shared/loading/loading.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/loading/loading.component.scss b/src/themes/custom/app/shared/loading/loading.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/loading/loading.component.ts b/src/themes/custom/app/shared/loading/loading.component.ts new file mode 100644 index 0000000000..fb1a291dc0 --- /dev/null +++ b/src/themes/custom/app/shared/loading/loading.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { LoadingComponent as BaseComponent } from '../../../../../app/shared/loading/loading.component'; + +@Component({ + selector: 'ds-loading', + styleUrls: ['../../../../../app/shared/loading/loading.component.scss'], + // styleUrls: ['./loading.component.scss'], + templateUrl: '../../../../../app/shared/loading/loading.component.html' + // templateUrl: './loading.component.html' +}) +export class LoadingComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.html b/src/themes/custom/app/shared/search/search-results/search-results.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.scss b/src/themes/custom/app/shared/search/search-results/search-results.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/search/search-results/search-results.component.ts b/src/themes/custom/app/shared/search/search-results/search-results.component.ts new file mode 100644 index 0000000000..5084d4d405 --- /dev/null +++ b/src/themes/custom/app/shared/search/search-results/search-results.component.ts @@ -0,0 +1,17 @@ +import { SearchResultsComponent as BaseComponent } from '../../../../../../app/shared/search/search-results/search-results.component'; +import { Component } from '@angular/core'; +import { fadeIn, fadeInOut } from '../../../../../../app/shared/animations/fade'; + +@Component({ + selector: 'ds-search-results', + // templateUrl: './search-results.component.html', + templateUrl: '../../../../../../app/shared/search/search-results/search-results.component.html', + // styleUrls: ['./search-results.component.scss'], + animations: [ + fadeIn, + fadeInOut + ] +}) +export class SearchResultsComponent extends BaseComponent { + +} diff --git a/src/themes/custom/eager-theme.module.ts b/src/themes/custom/eager-theme.module.ts new file mode 100644 index 0000000000..4c75f63cc9 --- /dev/null +++ b/src/themes/custom/eager-theme.module.ts @@ -0,0 +1,96 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SharedModule } from '../../app/shared/shared.module'; +import { HomeNewsComponent } from './app/home-page/home-news/home-news.component'; +import { NavbarComponent } from './app/navbar/navbar.component'; +import { HeaderComponent } from './app/header/header.component'; +import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; +import { SearchModule } from '../../app/shared/search/search.module'; +import { RootModule } from '../../app/root.module'; +import { NavbarModule } from '../../app/navbar/navbar.module'; +import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; +import { ItemPageModule } from '../../app/item-page/item-page.module'; +import { FooterComponent } from './app/footer/footer.component'; +import { JournalComponent } from './app/entity-groups/journal-entities/item-pages/journal/journal.component'; +import { + JournalIssueComponent +} from './app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component'; +import { + JournalVolumeComponent +} from './app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; +import { UntypedItemComponent } from './app/item-page/simple/item-types/untyped-item/untyped-item.component'; +import { ItemSharedModule } from '../../app/item-page/item-shared.module'; +import { + CreateCollectionParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { + CreateCommunityParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { + CreateItemParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { + EditCollectionSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { + EditCommunitySelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { + EditItemSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + +/** + * Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS. + * This will ensure that decorator gets picked up when the app loads + */ +const ENTRY_COMPONENTS = [ + JournalComponent, + JournalIssueComponent, + JournalVolumeComponent, + PublicationComponent, + UntypedItemComponent, +]; + +const DECLARATIONS = [ + ...ENTRY_COMPONENTS, + HomeNewsComponent, + HeaderComponent, + HeaderNavbarWrapperComponent, + NavbarComponent, + FooterComponent, + CreateCollectionParentSelectorComponent, + CreateCommunityParentSelectorComponent, + CreateItemParentSelectorComponent, + EditCollectionSelectorComponent, + EditCommunitySelectorComponent, + EditItemSelectorComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + SearchModule, + FormsModule, + RootModule, + NavbarModule, + ItemPageModule, + ItemSharedModule, + ], + declarations: DECLARATIONS, + providers: [ + ...ENTRY_COMPONENTS.map((component) => ({ provide: component })) + ], +}) +/** + * This module is included in the main bundle that gets downloaded at first page load. So it should + * contain only the themed components that have to be available immediately for the first page load, + * and the minimal set of imports required to make them work. Anything you can cut from it will make + * the initial page load faster, but may cause the page to flicker as components that were already + * rendered server side need to be lazy-loaded again client side + * + * Themed EntryComponents should also be added here + */ +export class EagerThemeModule { +} diff --git a/src/themes/custom/entry-components.ts b/src/themes/custom/entry-components.ts deleted file mode 100644 index b518e4cc45..0000000000 --- a/src/themes/custom/entry-components.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; - -export const ENTRY_COMPONENTS = [ - PublicationComponent -]; diff --git a/src/themes/custom/theme.module.ts b/src/themes/custom/lazy-theme.module.ts similarity index 81% rename from src/themes/custom/theme.module.ts rename to src/themes/custom/lazy-theme.module.ts index e2e97b9087..5b9d5b7230 100644 --- a/src/themes/custom/theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -28,35 +28,43 @@ import { StatisticsModule } from '../../app/statistics/statistics.module'; import { StoreModule } from '@ngrx/store'; import { StoreRouterConnectingModule } from '@ngrx/router-store'; import { TranslateModule } from '@ngx-translate/core'; -import { HomeNewsComponent } from './app/home-page/home-news/home-news.component'; -import { HomePageComponent } from './app/home-page/home-page.component'; import { HomePageModule } from '../../app/home-page/home-page.module'; -import { RootComponent } from './app/root/root.component'; import { AppModule } from '../../app/app.module'; -import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; import { ItemPageModule } from '../../app/item-page/item-page.module'; import { RouterModule } from '@angular/router'; -import { AccessControlModule } from '../../app/access-control/access-control.module'; +import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module'; +import { InfoModule } from '../../app/info/info.module'; +import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module'; +import { CommunityPageModule } from '../../app/community-page/community-page.module'; +import { CollectionPageModule } from '../../app/collection-page/collection-page.module'; +import { SubmissionModule } from '../../app/submission/submission.module'; +import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module'; +import { SearchModule } from '../../app/shared/search/search.module'; +import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module'; +import { ComcolModule } from '../../app/shared/comcol/comcol.module'; +import { RootModule } from '../../app/root.module'; +import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component'; +import { HomePageComponent } from './app/home-page/home-page.component'; +import { RootComponent } from './app/root/root.component'; import { BrowseBySwitcherComponent } from './app/browse-by/browse-by-switcher/browse-by-switcher.component'; import { CommunityListPageComponent } from './app/community-list-page/community-list-page.component'; -import { CommunityListPageModule } from '../../app/community-list-page/community-list-page.module'; import { SearchPageComponent } from './app/search-page/search-page.component'; -import { InfoModule } from '../../app/info/info.module'; +import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component'; import { EndUserAgreementComponent } from './app/info/end-user-agreement/end-user-agreement.component'; import { PageNotFoundComponent } from './app/pagenotfound/pagenotfound.component'; import { ObjectNotFoundComponent } from './app/lookup-by-id/objectnotfound/objectnotfound.component'; import { ForbiddenComponent } from './app/forbidden/forbidden.component'; import { PrivacyComponent } from './app/info/privacy/privacy.component'; -import { CollectionStatisticsPageComponent } from './app/statistics-page/collection-statistics-page/collection-statistics-page.component'; -import { CommunityStatisticsPageComponent } from './app/statistics-page/community-statistics-page/community-statistics-page.component'; -import { StatisticsPageModule } from '../../app/statistics-page/statistics-page.module'; +import { + CollectionStatisticsPageComponent +} from './app/statistics-page/collection-statistics-page/collection-statistics-page.component'; +import { + CommunityStatisticsPageComponent +} from './app/statistics-page/community-statistics-page/community-statistics-page.component'; import { ItemStatisticsPageComponent } from './app/statistics-page/item-statistics-page/item-statistics-page.component'; import { SiteStatisticsPageComponent } from './app/statistics-page/site-statistics-page/site-statistics-page.component'; import { CommunityPageComponent } from './app/community-page/community-page.component'; import { CollectionPageComponent } from './app/collection-page/collection-page.component'; -import { CommunityPageModule } from '../../app/community-page/community-page.module'; -import { CollectionPageModule } from '../../app/collection-page/collection-page.module'; -import { ConfigurationSearchPageComponent } from './app/search-page/configuration-search-page.component'; import { ItemPageComponent } from './app/item-page/simple/item-page.component'; import { FullItemPageComponent } from './app/item-page/full/full-item-page.component'; import { LoginPageComponent } from './app/login-page/login-page.component'; @@ -66,31 +74,36 @@ import { ForgotEmailComponent } from './app/forgot-password/forgot-password-emai import { ForgotPasswordFormComponent } from './app/forgot-password/forgot-password-form/forgot-password-form.component'; import { ProfilePageComponent } from './app/profile-page/profile-page.component'; import { RegisterEmailComponent } from './app/register-page/register-email/register-email.component'; -import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component'; -import { SubmissionImportExternalComponent } from './app/submission/import-external/submission-import-external.component'; -import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component'; import { MyDSpacePageComponent } from './app/my-dspace-page/my-dspace-page.component'; -import { WorkflowItemSendBackComponent } from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component'; -import { WorkflowItemDeleteComponent } from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component'; -import { SubmissionModule } from '../../app/submission/submission.module'; -import { MyDSpacePageModule } from '../../app/my-dspace-page/my-dspace-page.module'; -import { NavbarComponent } from './app/navbar/navbar.component'; -import { HeaderComponent } from './app/header/header.component'; -import { FooterComponent } from './app/footer/footer.component'; +import { SubmissionEditComponent } from './app/submission/edit/submission-edit.component'; +import { + SubmissionImportExternalComponent +} from './app/submission/import-external/submission-import-external.component'; +import { SubmissionSubmitComponent } from './app/submission/submit/submission-submit.component'; +import { WorkflowItemDeleteComponent +} from './app/workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component'; +import { + WorkflowItemSendBackComponent +} from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component'; import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component'; -import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; -import { FileSectionComponent } from './app/item-page/simple/field-components/file-section/file-section.component'; -import { SearchModule } from '../../app/shared/search/search.module'; -import { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.module'; -import { ComcolModule } from '../../app/shared/comcol/comcol.module'; import { FeedbackComponent } from './app/info/feedback/feedback.component'; +import { CommunityListComponent } from './app/community-list-page/community-list/community-list.component'; +import { AuthNavMenuComponent } from './app/shared/auth-nav-menu/auth-nav-menu.component'; +import { + ExpandableNavbarSectionComponent +} from './app/navbar/expandable-navbar-section/expandable-navbar-section.component'; +import { ItemMetadataComponent } from './app/item-page/edit-item-page/item-metadata/item-metadata.component'; +import { + EditItemTemplatePageComponent +} from './app/collection-page/edit-item-template-page/edit-item-template-page.component'; +import { LoadingComponent } from './app/shared/loading/loading.component'; +import { SearchResultsComponent } from './app/shared/search/search-results/search-results.component'; +import { AdminSidebarComponent } from './app/admin/admin-sidebar/admin-sidebar.component'; const DECLARATIONS = [ FileSectionComponent, HomePageComponent, - HomeNewsComponent, RootComponent, - PublicationComponent, BrowseBySwitcherComponent, CommunityListPageComponent, SearchPageComponent, @@ -121,21 +134,25 @@ const DECLARATIONS = [ SubmissionSubmitComponent, WorkflowItemDeleteComponent, WorkflowItemSendBackComponent, - FooterComponent, - HeaderComponent, - NavbarComponent, - HeaderNavbarWrapperComponent, BreadcrumbsComponent, - FeedbackComponent + FeedbackComponent, + CommunityListComponent, + AuthNavMenuComponent, + ExpandableNavbarSectionComponent, + ItemMetadataComponent, + EditItemTemplatePageComponent, + LoadingComponent, + SearchResultsComponent, + AdminSidebarComponent, ]; @NgModule({ imports: [ - AccessControlModule, AdminRegistriesModule, AdminSearchModule, AdminWorkflowModuleModule, AppModule, + RootModule, BitstreamFormatsModule, BrowseByModule, CollectionFormModule, @@ -176,9 +193,9 @@ const DECLARATIONS = [ SearchModule, FormsModule, ResourcePoliciesModule, - ComcolModule + ComcolModule, ], - declarations: DECLARATIONS + declarations: DECLARATIONS, }) /** @@ -188,5 +205,5 @@ const DECLARATIONS = [ * It is purposefully not exported, it should never be imported anywhere else, its only purpose is * to give lazily loaded components a context in which they can be compiled successfully */ -class ThemeModule { +class LazyThemeModule { } diff --git a/src/themes/custom/styles/theme.scss b/src/themes/custom/styles/theme.scss index 35810b15a6..05c96f3372 100644 --- a/src/themes/custom/styles/theme.scss +++ b/src/themes/custom/styles/theme.scss @@ -4,8 +4,7 @@ @import '../../../styles/_variables.scss'; @import '../../../styles/_mixins.scss'; @import '../../../styles/helpers/font_awesome_imports.scss'; -@import '../../../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../../../node_modules/nouislider/distribute/nouislider.min'; +@import '../../../styles/_vendor.scss'; @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; diff --git a/src/themes/dspace/app/header/header.component.html b/src/themes/dspace/app/header/header.component.html index cf691ea6c4..0fd4d42518 100644 --- a/src/themes/dspace/app/header/header.component.html +++ b/src/themes/dspace/app/header/header.component.html @@ -8,7 +8,7 @@
- +