diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeddb37441..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 @@ -82,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 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 b27e636620..bdd0d4e589 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ package-lock.json .env /nbproject/ + +junit.xml diff --git a/README.md b/README.md index ca27ce9ebe..cef95a45fa 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. @@ -330,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 7c880b7c36..2ece0c5e7d 100644 --- a/angular.json +++ b/angular.json @@ -47,7 +47,6 @@ ], "styles": [ "src/styles/startup.scss", - "./node_modules/ngx-ui-switch/ui-switch.component.css", { "input": "src/styles/base-theme.scss", "inject": false, @@ -64,7 +63,8 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { "development": { diff --git a/config/config.example.yml b/config/config.example.yml index 77134d0075..3f88b32324 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 @@ -164,10 +166,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: diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/integration/my-dspace.spec.ts index eb931adda7..fa923dbcbc 100644 --- a/cypress/integration/my-dspace.spec.ts +++ b/cypress/integration/my-dspace.spec.ts @@ -65,7 +65,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // Open the New Submission dropdown - cy.get('#dropdownSubmission').click(); + cy.get('button[data-test="submission-dropdown"]').click(); // Click on the "Item" type in that dropdown cy.get('#entityControlsDropdownMenu button[title="none"]').click(); @@ -98,7 +98,7 @@ describe('My DSpace page', () => { const id = subpaths[2]; // Click the "Save for Later" button to save this submission - cy.get('button#saveForLater').click(); + 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'); @@ -122,7 +122,7 @@ describe('My DSpace page', () => { cy.url().should('include', '/workspaceitems/' + id + '/edit'); // Discard our new submission by clicking Discard in Submission form & confirming - cy.get('button#discard').click(); + cy.get('ds-submission-form-footer [data-test="discard"]').click(); cy.get('button#discard_submit').click(); // Discarding should send us back to MyDSpace @@ -135,7 +135,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // Open the New Import dropdown - cy.get('#dropdownImport').click(); + cy.get('button[data-test="import-dropdown"]').click(); // Click on the "Item" type in that dropdown cy.get('#importControlsDropdownMenu button[title="none"]').click(); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index de279c7f2e..623c370c56 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -24,7 +24,7 @@ describe('Search Page', () => { // 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( diff --git a/cypress/support/index.ts b/cypress/support/index.ts index d9b6409a0d..024b46cdde 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -21,7 +21,7 @@ import './commands'; import 'cypress-axe'; // Runs once before the first test in each "block" -before(() => { +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}'); diff --git a/docker/README.md b/docker/README.md index d6fe0e6646..1a9fee0a81 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,7 +1,9 @@ # 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. +: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 diff --git a/docker/db.entities.yml b/docker/db.entities.yml index d1dfdf4a26..6473bf2e38 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -25,7 +25,7 @@ services: ### 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 3bd8f52630..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 diff --git a/package.json b/package.json index 236e4a7be5..dbb4cca8a5 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": "ng serve -c development", + "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 -c development", + "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", @@ -37,6 +38,7 @@ "cypress:open": "cypress open", "cypress:run": "cypress run", "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": { @@ -68,8 +70,8 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", - "@ng-dynamic-forms/core": "^14.0.1", - "@ng-dynamic-forms/ui-ng-bootstrap": "^14.0.1", + "@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", @@ -77,7 +79,8 @@ "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", "angular-idle-preload": "3.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", @@ -104,7 +107,7 @@ "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", - "moment": "^2.29.1", + "moment": "^2.29.2", "morgan": "^1.10.0", "ng-mocks": "^13.1.1", "ng2-file-upload": "1.4.0", @@ -119,7 +122,7 @@ "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.6", @@ -155,14 +158,14 @@ "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", "axe-core": "^4.3.3", - "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": "^6.2.0", "css-minimizer-webpack-plugin": "^3.4.1", "cssnano": "^5.0.6", "cypress": "9.5.1", - "cypress-axe": "^0.13.0", + "cypress-axe": "^0.14.0", "debug-loader": "^0.0.1", "deep-freeze": "0.0.1", "dotenv": "^8.2.0", @@ -171,10 +174,11 @@ "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", "jasmine-core": "^3.8.0", - "jasmine-marbles": "0.6.0", + "jasmine-marbles": "0.9.2", "jasmine-spec-reporter": "~5.0.0", "karma": "^6.3.14", "karma-chrome-launcher": "~3.1.0", @@ -182,7 +186,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ngx-mask": "^12.0.0", + "ngx-mask": "^13.1.7", "nodemon": "^2.0.15", "postcss": "^8.1", "postcss-apply": "0.12.0", @@ -196,7 +200,7 @@ "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", 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/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/server.ts b/server.ts index bc840fb73c..9fe03fe5b5 100644 --- a/server.ts +++ b/server.ts @@ -19,12 +19,14 @@ 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/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index 2307f3c6fa..09487a7eaa 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; -import { BrowserModule } from '@angular/platform-browser'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BrowserModule, By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; @@ -35,6 +35,7 @@ import { RouterMock } from '../../../shared/mocks/router.mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { Operation } from 'fast-json-patch'; import { ValidateGroupExists } from './validators/group-exists.validator'; +import { NoContent } from '../../../core/shared/NoContent.model'; describe('GroupFormComponent', () => { 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/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index e791b7f2a0..237dedde09 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -79,7 +79,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-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts index 614b2ef49b..870458fa9f 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -1,9 +1,9 @@ import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getNotificationsModuleRoute } from '../admin-routing-paths'; -export const NOTIFICATIONS_EDIT_PATH = 'openaire-broker'; +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; export const NOTIFICATIONS_RECITER_SUGGESTION_PATH = 'suggestion-targets'; -export function getNotificationsOpenairebrokerRoute(id: string) { - return new URLCombiner(getNotificationsModuleRoute(), NOTIFICATIONS_EDIT_PATH, id).toString(); +export function getQualityAssuranceRoute(id: string) { + return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); } diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts index 12bc7b9ec7..ca6a8cf572 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -4,9 +4,17 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; -import { NOTIFICATIONS_EDIT_PATH, NOTIFICATIONS_RECITER_SUGGESTION_PATH } from './admin-notifications-routing-paths'; +import { NOTIFICATIONS_RECITER_SUGGESTION_PATH } from './admin-notifications-routing-paths'; import { AdminNotificationsSuggestionTargetsPageComponent } from './admin-notifications-suggestion-targets-page/admin-notifications-suggestion-targets-page.component'; import { AdminNotificationsSuggestionTargetsPageResolver } from './admin-notifications-suggestion-targets-page/admin-notifications-suggestion-targets-page-resolver.service'; +import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; +import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { SourceDataResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.reslover'; @NgModule({ imports: [ @@ -26,12 +34,61 @@ import { AdminNotificationsSuggestionTargetsPageResolver } from './admin-notific showBreadcrumbsFluid: false } }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, + component: AdminQualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}`, + component: AdminQualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: AdminQualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: AdminQualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } ]) ], providers: [ I18nBreadcrumbResolver, I18nBreadcrumbsService, - AdminNotificationsSuggestionTargetsPageResolver + AdminNotificationsSuggestionTargetsPageResolver, + SourceDataResolver, + AdminQualityAssuranceTopicsPageResolver, + AdminQualityAssuranceEventsPageResolver, ] }) /** diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts index 47125daad6..53b3aada6e 100644 --- a/src/app/admin/admin-notifications/admin-notifications.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -3,8 +3,11 @@ import { NgModule } from '@angular/core'; import { CoreModule } from '../../core/core.module'; import { SharedModule } from '../../shared/shared.module'; import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; -import { OpenaireModule } from '../../openaire/openaire.module'; import { AdminNotificationsSuggestionTargetsPageComponent } from './admin-notifications-suggestion-targets-page/admin-notifications-suggestion-targets-page.component'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import {SuggestionNotificationsModule} from '../../suggestion-notifications/suggestion-notifications.module'; @NgModule({ imports: [ @@ -12,10 +15,13 @@ import { AdminNotificationsSuggestionTargetsPageComponent } from './admin-notifi SharedModule, CoreModule.forRoot(), AdminNotificationsRoutingModule, - OpenaireModule + SuggestionNotificationsModule ], declarations: [ - AdminNotificationsSuggestionTargetsPageComponent + AdminNotificationsSuggestionTargetsPageComponent, + AdminQualityAssuranceTopicsPageComponent, + AdminQualityAssuranceEventsPageComponent, + AdminQualityAssuranceSourcePageComponent ], entryComponents: [] }) diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html new file mode 100644 index 0000000000..315209d342 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts new file mode 100644 index 0000000000..b952078215 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page.component'; + +describe('AdminQualityAssuranceEventsPageComponent', () => { + let component: AdminQualityAssuranceEventsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceEventsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceEventsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceEventsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts new file mode 100644 index 0000000000..a1e15d5bdb --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-quality-assurance-events-page', + templateUrl: './admin-quality-assurance-events-page.component.html' +}) +export class AdminQualityAssuranceEventsPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts new file mode 100644 index 0000000000..3139355629 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceEventsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceEventsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceEventsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceEventsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.reslover.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.reslover.ts new file mode 100644 index 0000000000..8475732aed --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.reslover.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceService } from '../../../suggestion-notifications/qa/source/quality-assurance-source.service'; +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class SourceDataResolver implements Resolve> { + /** + * Initialize the effect class variables. + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + */ + constructor( + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private router: Router + ) { } + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.qualityAssuranceSourceService.getSources(5,0).pipe( + map((sources: PaginatedList) => { + if (sources.page.length === 1) { + this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]); + } + return sources.page; + })); + } + + /** + * + * @param route url path + * @returns url path + */ + getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts new file mode 100644 index 0000000000..ac9bdb48d6 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceSourcePageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceSourcePageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceSourcePageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceSourcePageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html new file mode 100644 index 0000000000..709103cf3d --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts new file mode 100644 index 0000000000..451c911c4c --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts @@ -0,0 +1,27 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page.component'; + +describe('AdminQualityAssuranceSourcePageComponent', () => { + let component: AdminQualityAssuranceSourcePageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceSourcePageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceSourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceSourcePageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts new file mode 100644 index 0000000000..624e71f281 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts @@ -0,0 +1,7 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'ds-admin-quality-assurance-source-page-component', + templateUrl: './admin-quality-assurance-source-page.component.html', +}) +export class AdminQualityAssuranceSourcePageComponent {} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts new file mode 100644 index 0000000000..47500d1878 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceTopicsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceTopicsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceTopicsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceTopicsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html new file mode 100644 index 0000000000..fc905ad724 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts new file mode 100644 index 0000000000..a32f60f017 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page.component'; + +describe('AdminQualityAssuranceTopicsPageComponent', () => { + let component: AdminQualityAssuranceTopicsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceTopicsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceTopicsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceTopicsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts new file mode 100644 index 0000000000..1b4f1d70aa --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-notification-qa-page', + templateUrl: './admin-quality-assurance-topics-page.component.html' +}) +export class AdminQualityAssuranceTopicsPageComponent { + +} 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-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-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 2a5a544a40..3aca092b52 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -182,176 +182,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, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the import section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'import', visible: true, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the export section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'export', visible: true, - })); - }); - }); - - 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, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'import', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'export', 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 c13599be9d..00c9e69f2c 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,47 +1,14 @@ import { Component, HostListener, Injector, OnInit } from '@angular/core'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { BehaviorSubject, combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, first, map, take, 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 { - METADATA_EXPORT_SCRIPT_NAME, - METADATA_IMPORT_SCRIPT_NAME, - 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 { 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 {NOTIFICATIONS_RECITER_SUGGESTION_PATH} from '../admin-notifications/admin-notifications-routing-paths'; +import { ActivatedRoute } from '@angular/router'; import { MenuID } from '../../shared/menu/menu-id.model'; -import { MenuItemType } from '../../shared/menu/menu-item-type.model'; /** * Component representing the admin sidebar @@ -86,11 +53,9 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { constructor( protected menuService: MenuService, protected injector: Injector, - protected variableService: CSSVariableService, - protected authService: AuthService, - protected modalService: NgbModal, + private variableService: CSSVariableService, + private authService: AuthService, public authorizationService: AuthorizationDataService, - protected scriptDataService: ScriptDataService, public route: ActivatedRoute ) { super(menuService, injector, authorizationService, route); @@ -106,7 +71,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { this.authService.isAuthenticated() .subscribe((loggedIn: boolean) => { if (loggedIn) { - this.createMenu(); this.menuService.showMenu(this.menuID); } }); @@ -136,526 +100,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: 9 - // }, - - /* 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: 10 - // }, - - /* Processes */ - { - id: 'processes', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes' - } as LinkMenuItemModel, - icon: 'terminal', - index: 12 - }, - ]; - 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 = [ - // 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( - filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), - take(1) - ).subscribe(() => { - // Hides the export menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(this.menuID, { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'file-export', - index: 3, - shouldPersistOnRouteChange: true - }); - 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 = [ - // 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( - filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), - take(1) - ).subscribe(() => { - // Hides the import menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(this.menuID, { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'file-import', - index: 2 - }); - 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 = [ - /* Notifications */ - { - id: 'notifications', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.notifications' - } as TextMenuItemModel, - icon: 'bell', - index: 4 - }, - { - id: 'notifications_reciter', - parentID: 'notifications', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.notifications_reciter', - link: '/admin/notifications/' + NOTIFICATIONS_RECITER_SUGGESTION_PATH - } as LinkMenuItemModel, - }, - /* 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: 6 - }, - /* Registries */ - { - id: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries' - } as TextMenuItemModel, - icon: 'list', - index: 7 - }, - { - 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: 8 - }, - - /* 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: 5 - }, - ]; - - 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/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 9bf5518558..d50668917c 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,19 +31,27 @@ 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 { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; +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' }, { @@ -214,6 +226,11 @@ import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-rout 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), @@ -223,6 +240,12 @@ import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-rout ] } ], { + // 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..f2243d435e 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'; @@ -187,7 +187,7 @@ describe('App component', () => { link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('class', 'theme-css'); - link.setAttribute('href', '/custom-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 db217ce161..ee8c4d685f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,6 +12,7 @@ import { } from '@angular/core'; import { ActivatedRouteSnapshot, + ActivationEnd, NavigationCancel, NavigationEnd, NavigationStart, ResolveEnd, @@ -21,9 +22,9 @@ import { import { isEqual } from 'lodash'; import { BehaviorSubject, Observable, of } 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 { Angulartics2GoogleAnalytics } from 'angulartics2'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; @@ -48,6 +49,7 @@ 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 { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-app', @@ -72,7 +74,7 @@ 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 @@ -105,6 +107,7 @@ export class AppComponent implements OnInit, AfterViewInit { private localeService: LocaleService, private breadcrumbsService: BreadcrumbsService, private modalService: NgbModal, + private modalConfig: NgbModalConfig, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { @@ -121,7 +124,7 @@ export class AppComponent implements OnInit, AfterViewInit { this.themeService.getThemeName$().subscribe((themeName: string) => { if (isPlatformBrowser(this.platformId)) { // the theme css will never download server side, so this should only happen on the browser - this.isThemeCSSLoading$.next(true); + this.distinctNext(this.isThemeCSSLoading$, true); } if (hasValue(themeName)) { this.loadGlobalThemeConfig(themeName); @@ -165,6 +168,16 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { + /** 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)).pipe( distinctUntilChanged() ); @@ -196,37 +209,57 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - let resolveEndFound = false; + let updatingTheme = false; + let snapshot: ActivatedRouteSnapshot; + this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { - resolveEndFound = false; - this.isRouteLoading$.next(true); - } else if (event instanceof ResolveEnd) { - resolveEndFound = true; - const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; - this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe( - switchMap((changed) => { - if (changed) { - return this.isThemeCSSLoading$; - } else { - return [false]; - } - }) - ).subscribe((changed) => { - this.isThemeLoading$.next(changed); - }); - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel - ) { - if (!resolveEndFound) { - this.isThemeLoading$.next(false); + updatingTheme = false; + this.distinctNext(this.isRouteLoading$, true); + } else if (event instanceof ResolveEnd) { + // this is the earliest point where we have all the information we need + // to update the theme, but this event is not emitted on first load + this.updateTheme(event.urlAfterRedirects, event.state.root); + updatingTheme = true; + } else if (!updatingTheme && event instanceof ActivationEnd) { + // if there was no ResolveEnd, keep track of the snapshot... + snapshot = event.snapshot; + } else if (event instanceof NavigationEnd) { + if (!updatingTheme) { + // ...and use it to update the theme on NavigationEnd instead + this.updateTheme(event.urlAfterRedirects, snapshot); + updatingTheme = true; } - this.isRouteLoading$.next(false); + this.distinctNext(this.isRouteLoading$, false); + } else if (event instanceof NavigationCancel) { + if (!updatingTheme) { + this.distinctNext(this.isThemeLoading$, false); + } + this.distinctNext(this.isRouteLoading$, false); } }); } + /** + * Update the theme according to the current route, if applicable. + * @param urlAfterRedirects the current URL after redirects + * @param snapshot the current route snapshot + * @private + */ + private updateTheme(urlAfterRedirects: string, snapshot: ActivatedRouteSnapshot): void { + this.themeService.updateThemeOnRouteChange$(urlAfterRedirects, snapshot).pipe( + switchMap((changed) => { + if (changed) { + return this.isThemeCSSLoading$; + } else { + return [false]; + } + }) + ).subscribe((changed) => { + this.distinctNext(this.isThemeLoading$, changed); + }); + } + @HostListener('window:resize', ['$event']) public onResize(event): void { this.dispatchWindowSize(event.target.innerWidth, event.target.innerHeight); @@ -268,7 +301,7 @@ export class AppComponent implements OnInit, AfterViewInit { link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('class', 'theme-css'); - link.setAttribute('href', `/${encodeURIComponent(themeName)}-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 = () => { @@ -280,7 +313,7 @@ export class AppComponent implements OnInit, AfterViewInit { }); } // the fact that this callback is used, proves we're on the browser. - this.isThemeCSSLoading$.next(false); + this.distinctNext(this.isThemeCSSLoading$, false); }; head.appendChild(link); } @@ -375,4 +408,17 @@ export class AppComponent implements OnInit, AfterViewInit { } }); } + + /** + * Use nextValue to update a given BehaviorSubject, only if it differs from its current value + * + * @param bs a BehaviorSubject + * @param nextValue the next value for that BehaviorSubject + * @protected + */ + protected distinctNext(bs: BehaviorSubject, nextValue: T): void { + if (bs.getValue() !== nextValue) { + bs.next(nextValue); + } + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c133efdd5c..ebf9aa4937 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,4 +1,4 @@ -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 { AbstractControl } from '@angular/forms'; @@ -15,10 +15,6 @@ import { } 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'; @@ -27,48 +23,30 @@ 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 { ThemedAdminSidebarComponent } from './admin/admin-sidebar/themed-admin-sidebar.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; @@ -96,8 +74,9 @@ const IMPORTS = [ EffectsModule.forRoot(appEffects), StoreModule.forRoot(appReducers, storeModuleConfig), StoreRouterConnectingModule.forRoot(), - ThemedEntryComponentModule.withEntryComponents(), StoreDevModules, + EagerThemesModule, + RootModule, ]; const PROVIDERS = [ @@ -107,8 +86,8 @@ const PROVIDERS = [ }, { provide: APP_BASE_HREF, - useFactory: getBase, - deps: [APP_CONFIG] + useFactory: getBaseHref, + deps: [DOCUMENT, APP_CONFIG] }, { provide: USER_PROVIDED_META_REDUCERS, @@ -162,29 +141,6 @@ const PROVIDERS = [ const DECLARATIONS = [ AppComponent, - RootComponent, - ThemedRootComponent, - HeaderComponent, - ThemedHeaderComponent, - HeaderNavbarWrapperComponent, - ThemedHeaderNavbarWrapperComponent, - AdminSidebarComponent, - ThemedAdminSidebarComponent, - AdminSidebarSectionComponent, - ExpandableAdminSidebarSectionComponent, - FooterComponent, - ThemedFooterComponent, - PageNotFoundComponent, - ThemedPageNotFoundComponent, - NotificationComponent, - NotificationsBoardComponent, - BreadcrumbsComponent, - ThemedBreadcrumbsComponent, - ForbiddenComponent, - ThemedForbiddenComponent, - IdleModalComponent, - ThemedPageInternalServerErrorComponent, - PageInternalServerErrorComponent ]; const EXPORTS = [ 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 7f0e6815ed..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 @@ -43,6 +43,10 @@ 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; @@ -143,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], @@ -159,7 +182,10 @@ 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: { diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 6e4437e0e0..72033649b0 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'"> 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 { success: {}, error: {} }); - const objectCache = jasmine.createSpyObj('objectCache', { - remove: {} - }); const requestService = jasmine.createSpyObj('requestService', { setStaleByHrefSubstring: {} }); @@ -65,8 +61,7 @@ describe('CollectionMetadataComponent', () => { { provide: ItemTemplateDataService, useValue: itemTemplateServiceStub }, { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(collection) }) } } }, { provide: NotificationsService, useValue: notificationsService }, - { provide: ObjectCacheService, useValue: objectCache }, - { provide: RequestService, useValue: requestService } + { provide: RequestService, useValue: requestService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -95,21 +90,19 @@ describe('CollectionMetadataComponent', () => { }); describe('deleteItemTemplate', () => { - describe('when delete returns a success', () => { - beforeEach(() => { - (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); - comp.deleteItemTemplate(); - }); + beforeEach(() => { + (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); + comp.deleteItemTemplate(); + }); + it('should call ItemTemplateService.deleteByCollectionID', () => { + expect(itemTemplateService.deleteByCollectionID).toHaveBeenCalledWith(template, 'collection-id'); + }); + + describe('when delete returns a success', () => { it('should display a success notification', () => { expect(notificationsService.success).toHaveBeenCalled(); }); - - it('should reset related object and request cache', () => { - expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collectionTemplateHref); - expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(template.self); - expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(collection.self); - }); }); describe('when delete returns a failure', () => { diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index cfaad3767e..d4396fce17 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -8,10 +8,9 @@ import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { switchMap, tap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; @@ -38,8 +37,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.getCollectionEndpoint(collection.id)), - ); - - combineLatestObservable(collection$, template$, templateHref$).pipe( - switchMap(([collection, template, templateHref]) => { - return this.itemTemplateService.deleteByCollectionID(template, collection.uuid).pipe( - tap((success: boolean) => { - if (success) { - this.objectCache.remove(templateHref); - this.objectCache.remove(template.self); - this.requestService.setStaleByHrefSubstring(template.self); - this.requestService.setStaleByHrefSubstring(templateHref); - this.requestService.setStaleByHrefSubstring(collection.self); - } - }) - ); + combineLatestObservable(collection$, template$).pipe( + switchMap(([collection, template]) => { + return this.itemTemplateService.deleteByCollectionID(template, collection.uuid); }) ).subscribe((success: boolean) => { if (success) { diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index 985290a592..5a8ca5b7ab 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComcolModule } from '../../../shared/comcol/comcol.module'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; describe('CollectionRolesComponent', () => { @@ -79,6 +81,7 @@ describe('CollectionRolesComponent', () => { { 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/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-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.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index ec61fac613..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 @@ -25,6 +25,14 @@ 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.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index 2bc829a3b0..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 @@ -25,6 +25,13 @@ 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 da38d730a5..4db4cba612 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -12,13 +12,13 @@ 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, @@ -27,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> { @@ -48,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.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 f89fa21681..999ea863df 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -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'; @@ -211,6 +216,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 */ @@ -447,8 +468,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/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index fbe6ed6476..d18b1ccf9a 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -5,6 +5,8 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; 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 +15,7 @@ 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/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/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 61d587b6de..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 } diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 9001d334ce..1a42408f72 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -63,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 @@ -156,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 bde6831967..f18c262524 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -211,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 6d48242178..cdf87e5c1a 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -197,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()); } @@ -282,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/core.module.ts b/src/app/core/core.module.ts index 879ba76c7d..bd82e0adc0 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} 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'; @@ -162,13 +166,24 @@ 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 { OpenaireSuggestionTarget } from './openaire/reciter-suggestions/models/openaire-suggestion-target.model'; -import { OpenaireSuggestion } from './openaire/reciter-suggestions/models/openaire-suggestion.model'; -import { OpenaireSuggestionSource } from './openaire/reciter-suggestions/models/openaire-suggestion-source.model'; +import { OpenaireSuggestionTarget } from './suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestion } from './suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestionSource } from './suggestion-notifications/reciter-suggestions/models/openaire-suggestion-source.model'; 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 {SubmissionAccessesModel} from './config/models/config-submission-accesses.model'; +import { QualityAssuranceTopicObject } from './suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceSourceObject } from './suggestion-notifications/qa/models/quality-assurance-source.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 { 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'; +import {QualityAssuranceEventObject} from './suggestion-notifications/qa/models/quality-assurance-event.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -193,7 +208,6 @@ const DECLARATIONS = []; const EXPORTS = []; const PROVIDERS = [ - ApiService, AuthenticatedGuard, CommunityDataService, CollectionDataService, @@ -208,6 +222,7 @@ const PROVIDERS = [ SectionFormOperationsService, FormService, EPersonDataService, + LinkHeadService, HALEndpointService, HostWindowService, ItemDataService, @@ -226,6 +241,7 @@ const PROVIDERS = [ MyDSpaceResponseParsingService, ServerResponseService, BrowseService, + AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, SubmissionFormsConfigService, @@ -256,6 +272,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + DsDynamicTypeBindRelationService, EntityTypeService, ContentSourceResponseParsingService, ItemTemplateDataService, @@ -294,7 +311,10 @@ const PROVIDERS = [ GroupDataService, FeedbackDataService, ResearcherProfileService, - ProfileClaimService + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -355,10 +375,17 @@ export const models = OpenaireSuggestion, OpenaireSuggestionTarget, OpenaireSuggestionSource, + QualityAssuranceTopicObject, + QualityAssuranceEventObject, Root, SearchConfig, SubmissionAccessesModel, - ResearcherProfile + QualityAssuranceSourceObject, + AccessStatusObject, + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ 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/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index c1ebf90a47..30ef79ee6d 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -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/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 81d683b37a..dffc97f294 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -22,6 +22,7 @@ import { 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'; @@ -244,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/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index f680fed6a4..64efd58418 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -11,7 +11,11 @@ import { ObjectCacheService } from '../cache/object-cache.service'; 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 { PatchRequest } from './request.models'; @@ -25,9 +29,12 @@ import { RemoteData } from './remote-data'; 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'; +const BOOLEAN = { f: false, t: true }; + class TestService extends DataService { constructor( @@ -86,6 +93,9 @@ describe('DataService', () => { }, getObjectBySelfLink: () => { /* empty */ + }, + getByHref: () => { + /* empty */ } } as any; store = {} as Store; @@ -833,4 +843,149 @@ 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); + }); + }); + }); + + 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 + ); + }); + }); + }); + }); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 310ad704ec..227683c73c 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,21 +22,24 @@ 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 { ObjectCacheEntry } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; 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, + DeleteByIDRequest, + DeleteRequest, GetRequest, PatchRequest, - PutRequest, - DeleteRequest + PostRequest, + PutRequest } from './request.models'; import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; @@ -168,7 +172,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 +583,86 @@ export abstract class DataService implements UpdateDa return result$; } + /** +<<<<<<< HEAD + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param itemId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + public postOnRelated(itemId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a delete on an endpoint related item. Ex.: endpoint//related + * @param itemId The item id + * @return the RestResponse as an Observable + */ + public deleteOnRelated(itemId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', itemId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /* + * 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( + 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 +684,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 +703,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/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 1f8c8b2284..f27919844d 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -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-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index a2082831e0..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -28,6 +28,6 @@ export enum FeatureID { CanCreateVersion = 'canCreateVersion', CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', - ShowClaimItem = 'showClaimItem', CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index cc1e3b6e20..a4ed9f882f 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -10,12 +10,13 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; +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; @@ -36,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/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index a49761ae5d..fe35d840d7 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -8,7 +8,7 @@ import { defaultUUID, getMockUUIDService } from '../../shared/mocks/uuid.service import { ObjectCacheService } from '../cache/object-cache.service'; import { coreReducers} from '../core.reducers'; import { UUIDService } from '../shared/uuid.service'; -import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; +import { RequestConfigureAction, RequestExecuteAction, RequestStaleAction } from './request.actions'; import { DeleteRequest, GetRequest, @@ -19,7 +19,7 @@ import { PutRequest } from './request.models'; import { RequestService } from './request.service'; -import { TestBed, waitForAsync } from '@angular/core/testing'; +import { fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { storeModuleConfig } from '../../app.reducer'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { RequestEntryState } from './request-entry-state.model'; @@ -426,7 +426,7 @@ describe('RequestService', () => { describe('and it is cached', () => { describe('in the ObjectCache', () => { beforeEach(() => { - (objectCache.getByHref as any).and.returnValue(observableOf({ requestUUID: 'some-uuid' })); + (objectCache.getByHref as any).and.returnValue(observableOf({ requestUUIDs: ['some-uuid'] })); spyOn(serviceAsAny, 'hasByHref').and.returnValue(false); spyOn(serviceAsAny, 'hasByUUID').and.returnValue(true); }); @@ -596,4 +596,33 @@ describe('RequestService', () => { }); }); + describe('setStaleByUUID', () => { + let dispatchSpy: jasmine.Spy; + let getByUUIDSpy: jasmine.Spy; + + beforeEach(() => { + dispatchSpy = spyOn(store, 'dispatch'); + getByUUIDSpy = spyOn(service, 'getByUUID').and.callThrough(); + }); + + it('should dispatch a RequestStaleAction', () => { + service.setStaleByUUID('something'); + const firstAction = dispatchSpy.calls.argsFor(0)[0]; + expect(firstAction).toBeInstanceOf(RequestStaleAction); + expect(firstAction.payload).toEqual({ uuid: 'something' }); + }); + + it('should return an Observable that emits true as soon as the request is stale', fakeAsync(() => { + dispatchSpy.and.callFake(() => { /* empty */ }); // don't actually set as stale + getByUUIDSpy.and.returnValue(cold('a-b--c--d-', { // but fake the state in the cache + a: { state: RequestEntryState.ResponsePending }, + b: { state: RequestEntryState.Success }, + c: { state: RequestEntryState.SuccessStale }, + d: { state: RequestEntryState.Error }, + })); + + const done$ = service.setStaleByUUID('something'); + expect(done$).toBeObservable(cold('-----(t|)', { t: true })); + })); + }); }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 3903bcfc99..2d5acb2cb3 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -311,6 +311,21 @@ export class RequestService { ); } + /** + * Mark a request as stale + * @param uuid the UUID of the request + * @return an Observable that will emit true once the Request becomes stale + */ + setStaleByUUID(uuid: string): Observable { + this.store.dispatch(new RequestStaleAction(uuid)); + + return this.getByUUID(uuid).pipe( + map((request: RequestEntry) => isStale(request.state)), + filter((stale: boolean) => stale), + take(1), + ); + } + /** * Check if a GET request is in the cache or if it's still pending * @param {GetRequest} request The request to check @@ -339,7 +354,7 @@ export class RequestService { .subscribe((entry: ObjectCacheEntry) => { // if the object cache has a match, check if the request that the object came with is // still valid - inObjCache = this.hasByUUID(entry.requestUUID); + inObjCache = this.hasByUUID(entry.requestUUIDs[0]); }).unsubscribe(); // we should send the request if it isn't cached diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 207093b4d5..26ed370026 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -151,7 +151,7 @@ describe('VersionHistoryDataService', () => { describe('when getVersionsEndpoint is called', () => { it('should return the correct value', () => { service.getVersionsEndpoint(versionHistoryId).subscribe((res) => { - expect(res).toBe(url + '/versions'); + expect(res).toBe(url + '/versionhistories/version-history-id/versions'); }); }); }); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index e123253e2b..80cb92716a 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -21,7 +21,7 @@ import { EPersonDataService } from './eperson-data.service'; import { EPerson } from './models/eperson.model'; import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; @@ -287,13 +287,12 @@ describe('EPersonDataService', () => { describe('deleteEPerson', () => { beforeEach(() => { - spyOn(service, 'findById').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); + spyOn(service, 'delete').and.returnValue(createNoContentRemoteDataObject$()); service.deleteEPerson(EPersonMock).subscribe(); }); - it('should send DeleteRequest', () => { - const expected = new DeleteRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid); - expect(requestService.send).toHaveBeenCalledWith(expected); + it('should call DataService.delete with the EPerson\'s UUID', () => { + expect(service.delete).toHaveBeenCalledWith(EPersonMock.id); }); }); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 1052021479..68d2839d42 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -192,7 +192,7 @@ export class LocaleService { this.routeService.getCurrentUrl().pipe(take(1)).subscribe((currentURL) => { // Hard redirect to the reload page with a unique number behind it // so that all state is definitely lost - this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL); + this._window.nativeWindow.location.href = `reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL); }); } diff --git a/src/app/core/orcid/model/orcid-history.model.ts b/src/app/core/orcid/model/orcid-history.model.ts new file mode 100644 index 0000000000..ef8f30e0a3 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.model.ts @@ -0,0 +1,89 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_HISTORY } from './orcid-history.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid History. + */ +@typedObject +export class OrcidHistory extends CacheableObject { + + static type = ORCID_HISTORY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid History record + */ + @autoserialize + id: number; + + /** + * The name of the related entity + */ + @autoserialize + entityName: string; + + /** + * The identifier of the profileItem of this Orcid History record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid History record. + */ + @autoserialize + entityId: string; + + /** + * The type of the entity related to this Orcid History record. + */ + @autoserialize + entityType: string; + + /** + * The response status coming from ORCID api. + */ + @autoserialize + status: number; + + /** + * The putCode assigned by ORCID to the entity. + */ + @autoserialize + putCode: string; + + /** + * The last send attempt timestamp. + */ + lastAttempt: string; + + /** + * The success send attempt timestamp. + */ + successAttempt: string; + + /** + * The response coming from ORCID. + */ + responseMessage: string; + + /** + * The {@link HALLink}s for this Orcid History record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-history.resource-type.ts b/src/app/core/orcid/model/orcid-history.resource-type.ts new file mode 100644 index 0000000000..45da8cbf68 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_HISTORY = new ResourceType('orcidhistory'); diff --git a/src/app/core/orcid/model/orcid-queue.model.ts b/src/app/core/orcid/model/orcid-queue.model.ts new file mode 100644 index 0000000000..2a1c3f1d82 --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.model.ts @@ -0,0 +1,68 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_QUEUE } from './orcid-queue.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid Queue. + */ +@typedObject +export class OrcidQueue extends CacheableObject { + + static type = ORCID_QUEUE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid Queue record + */ + @autoserialize + id: number; + + /** + * The record description. + */ + @autoserialize + description: string; + + /** + * The identifier of the profileItem of this Orcid Queue record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid Queue record. + */ + @autoserialize + entityId: string; + + /** + * The type of this Orcid Queue record. + */ + @autoserialize + recordType: string; + + /** + * The operation related to this Orcid Queue record. + */ + @autoserialize + operation: string; + + /** + * The {@link HALLink}s for this Orcid Queue record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-queue.resource-type.ts b/src/app/core/orcid/model/orcid-queue.resource-type.ts new file mode 100644 index 0000000000..a7f40d70ec --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidQueue + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_QUEUE = new ResourceType('orcidqueue'); diff --git a/src/app/core/orcid/orcid-auth.service.spec.ts b/src/app/core/orcid/orcid-auth.service.spec.ts new file mode 100644 index 0000000000..27a33a85b1 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.spec.ts @@ -0,0 +1,329 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { OrcidAuthService } from './orcid-auth.service'; +import { ResearcherProfileService } from '../profile/researcher-profile.service'; + +describe('OrcidAuthService', () => { + let scheduler: TestScheduler; + let service: OrcidAuthService; + let serviceAsAny: any; + + let researcherProfileService: jasmine.SpyObj; + let configurationDataService: ConfigurationDataService; + let nativeWindowService: NativeWindowRefMock; + let routerStub: any; + + const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + + const researcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: false, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfilePatched: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: true, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + + beforeEach(() => { + scheduler = getTestScheduler(); + routerStub = new RouterMock(); + researcherProfileService = jasmine.createSpyObj('ResearcherProfileService', { + findById: jasmine.createSpy('findById'), + updateByOrcidOperations: jasmine.createSpy('updateByOrcidOperations') + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + nativeWindowService = new NativeWindowRefMock(); + + service = new OrcidAuthService( + nativeWindowService, + configurationDataService, + researcherProfileService, + routerStub); + + serviceAsAny = service; + }); + + + describe('isLinkedToOrcid', () => { + it('should return true when item has metadata', () => { + const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); + expect(result).toBeTrue(); + }); + + it('should return true when item has no metadata', () => { + const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); + expect(result).toBeFalse(); + }); + }); + + describe('onlyAdminCanDisconnectProfileFromOrcid', () => { + it('should return true when property is only_admin', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('ownerCanDisconnectProfileFromOrcid', () => { + it('should return true when property is admin_and_owner', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('linkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: 'test-code' + }]; + + scheduler.schedule(() => service.linkOrcidByItem(mockItemUnlinkedToOrcid, 'test-code').subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('unlinkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: RemoveOperation[] = [{ + path: '/orcid', + op: 'remove' + }]; + + scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('getOrcidAuthorizeUrl', () => { + beforeEach(() => { + routerStub.setRoute('/entities/person/uuid/orcid'); + (service as any).configurationService.findByPropertyName.and.returnValues( + createSuccessfulRemoteDataObject$(authorizeUrl), + createSuccessfulRemoteDataObject$(appClientId), + createSuccessfulRemoteDataObject$(orcidScope) + ); + }); + + it('should build the url properly', () => { + const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); + const redirectUri: string = new URLCombiner(nativeWindowService.nativeWindow.origin, encodeURIComponent(routerStub.url.split('?')[0])).toString(); + const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; + + const expected = cold('(a|)', { + a: url + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getOrcidAuthorizationScopesByItem', () => { + it('should return list of scopes saved in the item', () => { + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); + expect(result).toEqual(orcidScopes); + }); + }); + + describe('getOrcidAuthorizationScopes', () => { + it('should return list of scopes by configuration', () => { + (service as any).configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(orcidScope) + ); + const orcidScopes = [ + '/authenticate', + '/read-limited' + ]; + const expected = cold('(a|)', { + a: orcidScopes + }); + const result = service.getOrcidAuthorizationScopes(); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/orcid/orcid-auth.service.ts b/src/app/core/orcid/orcid-auth.service.ts new file mode 100644 index 0000000000..cf7bc2b259 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; + +import { ResearcherProfileService } from '../profile/researcher-profile.service'; +import { Item } from '../shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; + +@Injectable() +export class OrcidAuthService { + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private configurationService: ConfigurationDataService, + private researcherProfileService: ResearcherProfileService, + private router: Router) { + } + + /** + * Check if the given item is linked to an ORCID profile. + * + * @param item the item to check + * @returns the check result + */ + public isLinkedToOrcid(item: Item): boolean { + return item.hasMetadata('dspace.orcid.authenticated'); + } + + /** + * Returns true if only the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + public onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); + }) + ); + } + + /** + * Returns true if the profile's owner can disconnect that profile from ORCID. + * + * @returns the check result + */ + public ownerCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); + }) + ); + } + + /** + * Perform a link operation to ORCID profile. + * + * @param person The person item related to the researcher profile + * @param code The auth-code received from orcid + */ + public linkOrcidByItem(person: Item, code: string): Observable> { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: code + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Perform unlink operation from ORCID profile. + * + * @param person The person item related to the researcher profile + */ + public unlinkOrcidByItem(person: Item): Observable> { + const operations: RemoveOperation[] = [{ + path:'/orcid', + op:'remove' + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Build and return the url to authenticate with orcid + * + * @param profile + */ + public getOrcidAuthorizeUrl(profile: Item): Observable { + return combineLatest([ + this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] + ).pipe( + map(([authorizeUrl, clientId, scopes]) => { + const redirectUri = new URLCombiner(this._window.nativeWindow.origin, encodeURIComponent(this.router.url.split('?')[0])); + console.log(redirectUri.toString()); + return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope=' + + scopes.values.join(' '); + })); + } + + /** + * Return all orcid authorization scopes saved in the given item + * + * @param item + */ + public getOrcidAuthorizationScopesByItem(item: Item): string[] { + return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; + } + + /** + * Return all orcid authorization scopes available by configuration + */ + public getOrcidAuthorizationScopes(): Observable { + return this.configurationService.findByPropertyName('orcid.scope').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + ); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { + return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( + getFirstCompletedRemoteData() + ); + } + +} diff --git a/src/app/core/orcid/orcid-history-data.service.ts b/src/app/core/orcid/orcid-history-data.service.ts new file mode 100644 index 0000000000..cef3efbe78 --- /dev/null +++ b/src/app/core/orcid/orcid-history-data.service.ts @@ -0,0 +1,126 @@ +// eslint-disable-next-line max-classes-per-file +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, switchMap } 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 { DataService } from '../data/data.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { ItemDataService } from '../data/item-data.service'; +import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { OrcidHistory } from './model/orcid-history.model'; +import { ORCID_HISTORY } from './model/orcid-history.resource-type'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { CoreState } from '../core-state.model'; +import { RestRequest } from '../data/rest-request.model'; +import { sendRequest } from '../shared/request.operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidHistoryServiceImpl extends DataService { + public linkPath = 'orcidhistories'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid History endpoint. + */ +@Injectable() +@dataService(ORCID_HISTORY) +export class OrcidHistoryDataService { + + dataService: OrcidHistoryServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidHistoryServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + sendToORCID(orcidQueue: OrcidQueue): Observable> { + const requestId = this.requestService.generateRequestId(); + return this.getEndpoint().pipe( + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PostRequest(requestId, endpointURL, orcidQueue._links.self.href, options); + }), + sendRequest(this.requestService), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + ); + } + + getEndpoint(): Observable { + return this.halService.getEndpoint(this.dataService.linkPath); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link OrcidHistory}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link OrcidHistory} + * @param href The url of object we want to retrieve + * @param findListOptions Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/orcid/orcid-queue.service.ts b/src/app/core/orcid/orcid-queue.service.ts new file mode 100644 index 0000000000..30b9580b96 --- /dev/null +++ b/src/app/core/orcid/orcid-queue.service.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line max-classes-per-file +import { DataService } from '../data/data.service'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +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 { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { ORCID_QUEUE } from './model/orcid-queue.resource-type'; +import { ItemDataService } from '../data/item-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { NoContent } from '../shared/NoContent.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { Router } from '@angular/router'; +import { CoreState } from '../core-state.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidQueueServiceImpl extends DataService { + public linkPath = 'orcidqueues'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid Queue endpoint. + */ +@Injectable() +@dataService(ORCID_QUEUE) +export class OrcidQueueService { + + dataService: OrcidQueueServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected configurationService: ConfigurationDataService, + protected router: Router, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidQueueServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * @param itemId It represent an Id of profileItem + * @param paginationOptions The pagination options object + * @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 + * @returns { OrcidQueue } + */ + searchByProfileItemId(itemId: string, paginationOptions: PaginationComponentOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + return this.dataService.searchBy('findByProfileItem', { + searchParams: [new RequestParam('profileItemId', itemId)], + elementsPerPage: paginationOptions.pageSize, + currentPage: paginationOptions.currentPage + }, + useCachedVersionIfAvailable, + reRequestOnStale + ); + } + + /** + * @param orcidQueueId represents a id of orcid queue + * @returns { NoContent } + */ + deleteById(orcidQueueId: number): Observable> { + return this.dataService.delete(orcidQueueId.toString()); + } + + /** + * This method will set linkPath to stale + */ + clearFindByProfileItemRequests() { + this.requestService.setStaleByHrefSubstring(this.dataService.linkPath + '/search/findByProfileItem'); + } + +} diff --git a/src/app/core/pagination/pagination.service.spec.ts b/src/app/core/pagination/pagination.service.spec.ts index 94b6b48d59..66349e8a9e 100644 --- a/src/app/core/pagination/pagination.service.spec.ts +++ b/src/app/core/pagination/pagination.service.spec.ts @@ -12,7 +12,7 @@ describe('PaginationService', () => { let routeService; const defaultPagination = new PaginationComponentOptions(); - const defaultSort = new SortOptions('id', SortDirection.DESC); + const defaultSort = new SortOptions('dc.title', SortDirection.ASC); const defaultFindListOptions = new FindListOptions(); beforeEach(() => { @@ -39,7 +39,6 @@ describe('PaginationService', () => { service = new PaginationService(routeService, router); }); - describe('getCurrentPagination', () => { it('should retrieve the current pagination info from the routerService', () => { service.getCurrentPagination('test-id', defaultPagination).subscribe((currentPagination) => { @@ -56,6 +55,26 @@ describe('PaginationService', () => { expect(currentSort).toEqual(Object.assign(new SortOptions('score', SortDirection.ASC ))); }); }); + it('should return default sort when no sort specified', () => { + // This is same as routeService (defined above), but returns no sort field or direction + routeService = { + getQueryParameterValue: (param) => { + let value; + if (param.endsWith('.page')) { + value = 5; + } + if (param.endsWith('.rpp')) { + value = 10; + } + return observableOf(value); + } + }; + service = new PaginationService(routeService, router); + + service.getCurrentSort('test-id', defaultSort).subscribe((currentSort) => { + expect(currentSort).toEqual(defaultSort); + }); + }); }); describe('getFindListOptions', () => { it('should retrieve the current findListOptions info from the routerService', () => { diff --git a/src/app/core/pagination/pagination.service.ts b/src/app/core/pagination/pagination.service.ts index db80cc9476..40e13d654f 100644 --- a/src/app/core/pagination/pagination.service.ts +++ b/src/app/core/pagination/pagination.service.ts @@ -7,8 +7,8 @@ import { filter, map, take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { difference } from '../../shared/object.util'; -import { isNumeric } from 'rxjs/internal-compatibility'; import { FindListOptions } from '../data/find-list-options.model'; +import { isNumeric } from '../../shared/numeric.util'; @Injectable({ providedIn: 'root', @@ -24,7 +24,11 @@ import { FindListOptions } from '../data/find-list-options.model'; */ export class PaginationService { - private defaultSortOptions = new SortOptions('id', SortDirection.ASC); + /** + * Sort on title ASC by default + * @type {SortOptions} + */ + private defaultSortOptions = new SortOptions('dc.title', SortDirection.ASC); private clearParams = {}; diff --git a/src/app/core/profile/model/researcher-profile.model.ts b/src/app/core/profile/model/researcher-profile.model.ts index a07467476e..6c8b19db40 100644 --- a/src/app/core/profile/model/researcher-profile.model.ts +++ b/src/app/core/profile/model/researcher-profile.model.ts @@ -1,10 +1,15 @@ +import { Observable } from 'rxjs'; import { autoserialize, deserialize, deserializeAs } from 'cerialize'; -import { typedObject } from '../../cache/builders/build-decorators'; + +import { link, typedObject } from '../../cache/builders/build-decorators'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; import { RESEARCHER_PROFILE } from './researcher-profile.resource-type'; -import {CacheableObject} from '../../cache/cacheable-object.model'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { ITEM } from '../../shared/item.resource-type'; +import { Item } from '../../shared/item.model'; /** * Class the represents a Researcher Profile. @@ -46,4 +51,11 @@ export class ResearcherProfile extends CacheableObject { eperson: HALLink }; + /** + * The related person Item + * Will be undefined unless the item {@link HALLink} has been resolved. + */ + @link(ITEM) + item?: Observable>; + } diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts new file mode 100644 index 0000000000..899867ec8e --- /dev/null +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -0,0 +1,419 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { buildPaginatedList } from '../data/paginated-list.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { RestResponse } from '../cache/response.models'; +import { RequestEntry } from '../data/request-entry.model'; +import { ResearcherProfileService } from './researcher-profile.service'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from './model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +import { ReplaceOperation } from 'fast-json-patch'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { PostRequest } from '../data/request.models'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; + +describe('ResearcherProfileService', () => { + let scheduler: TestScheduler; + let service: ResearcherProfileService; + let serviceAsAny: any; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + let routerStub: any; + + const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const researcherProfileItem: Item = Object.assign(new Item(), { + id: itemId, + _links: { + self: { + href: `https://rest.api/rest/api/items/${itemId}` + }, + } + }); + const researcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: false, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfilePatched: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: true, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfileId2 = 'agbf9946-f4ce-479e-8f11-b90cbe9f7241'; + const anotherResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId2, + visible: false, + type: 'profile', + _links: { + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId2}` + }, + } + }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + + const endpointURL = `https://rest.api/rest/api/profiles`; + const endpointURLWithEmbed = 'https://rest.api/rest/api/profiles?embed=item'; + const sourceUri = `https://rest.api/rest/api/external-source/profile`; + const requestURL = `https://rest.api/rest/api/profiles/${researcherProfileId}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [researcherProfile, anotherResearcherProfile]; + const paginatedList = buildPaginatedList(pageInfo, array); + const researcherProfileRD = createSuccessfulRemoteDataObject(researcherProfile); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring') + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: researcherProfileRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + buildFromRequestUUID: hot('a|', { + a: researcherProfileRD + }) + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + routerStub = new RouterMock(); + const itemService = jasmine.createSpyObj('ItemService', { + findByHref: jasmine.createSpy('findByHref') + }); + + service = new ResearcherProfileService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + routerStub, + comparator, + itemService + ); + serviceAsAny = service; + + spyOn((service as any).dataService, 'create').and.callThrough(); + spyOn((service as any).dataService, 'delete').and.callThrough(); + spyOn((service as any).dataService, 'update').and.callThrough(); + spyOn((service as any).dataService, 'findById').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + spyOn((service as any).dataService, 'searchBy').and.callThrough(); + spyOn((service as any).dataService, 'getLinkPath').and.returnValue(observableOf(endpointURL)); + + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findById(researcherProfileId)); + scheduler.flush(); + + expect((service as any).dataService.findById).toHaveBeenCalledWith(researcherProfileId, true, true); + }); + + it('should return a ResearcherProfile object with the given id', () => { + const result = service.findById(researcherProfileId); + const expected = cold('a|', { + a: researcherProfileRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('create', () => { + it('should proxy the call to dataservice.create with eperson UUID', () => { + scheduler.schedule(() => service.create()); + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalled(); + }); + + it('should return the RemoteData created', () => { + const result = service.create(); + const expected = cold('a|', { + a: researcherProfileRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('delete', () => { + it('should proxy the call to dataservice.delete', () => { + scheduler.schedule(() => service.delete(researcherProfile)); + scheduler.flush(); + + expect((service as any).dataService.delete).toHaveBeenCalledWith(researcherProfile.id); + }); + }); + + describe('findRelatedItemId', () => { + describe('with a related item', () => { + + beforeEach(() => { + (service as any).itemService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfileItem)); + }); + + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findRelatedItemId(researcherProfile)); + scheduler.flush(); + + expect((service as any).itemService.findByHref).toHaveBeenCalledWith(researcherProfile._links.item.href, false); + }); + + it('should return a ResearcherProfile object with the given id', () => { + const result = service.findRelatedItemId(researcherProfile); + const expected = cold('(a|)', { + a: itemId + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('without a related item', () => { + + beforeEach(() => { + (service as any).itemService.findByHref.and.returnValue(createNoContentRemoteDataObject$()); + }); + + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findRelatedItemId(researcherProfile)); + scheduler.flush(); + + expect((service as any).itemService.findByHref).toHaveBeenCalledWith(researcherProfile._links.item.href, false); + }); + + it('should not return a ResearcherProfile object with the given id', () => { + const result = service.findRelatedItemId(researcherProfile); + const expected = cold('(a|)', { + a: null + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('setVisibility', () => { + let patchSpy; + beforeEach(() => { + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should proxy the call to dataservice.patch', () => { + const replaceOperation: ReplaceOperation = { + path: '/visible', + op: 'replace', + value: true + }; + + scheduler.schedule(() => service.setVisibility(researcherProfile, true)); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, [replaceOperation]); + }); + }); + + describe('createFromExternalSource', () => { + + beforeEach(() => { + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should proxy the call to dataservice.patch', () => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const request = new PostRequest(requestUUID, endpointURLWithEmbed, sourceUri, options); + + scheduler.schedule(() => service.createFromExternalSource(sourceUri)); + scheduler.flush(); + + expect((service as any).requestService.send).toHaveBeenCalledWith(request); + expect((service as any).rdbService.buildFromRequestUUID).toHaveBeenCalledWith(requestUUID, followLink('item')); + + }); + }); + + describe('updateByOrcidOperations', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should call patch method properly', () => { + scheduler.schedule(() => service.updateByOrcidOperations(researcherProfile, []).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, []); + }); + }); +}); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 0220afb964..882845d133 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -2,54 +2,50 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; + import { Store } from '@ngrx/store'; -import { Operation, RemoveOperation, ReplaceOperation } from 'fast-json-patch'; -import { combineLatest, Observable, of as observableOf } from 'rxjs'; -import { catchError, find, map, switchMap, tap } from 'rxjs/operators'; -import { environment } from '../../../environments/environment'; +import { Operation, ReplaceOperation } from 'fast-json-patch'; +import { Observable } from 'rxjs'; +import { find, map } 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 { ConfigurationDataService } from '../data/configuration-data.service'; import { DataService } from '../data/data.service'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ItemDataService } from '../data/item-data.service'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Item } from '../shared/item.model'; import { NoContent } from '../shared/NoContent.model'; -import { - getFinishedRemoteData, - getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload -} from '../shared/operators'; +import { getAllCompletedRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; import { ResearcherProfile } from './model/researcher-profile.model'; import { RESEARCHER_PROFILE } from './model/researcher-profile.resource-type'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; -import { hasValue } from '../../shared/empty.util'; -import {CoreState} from '../core-state.model'; +import { hasValue, isEmpty } from '../../shared/empty.util'; +import { CoreState } from '../core-state.model'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Item } from '../shared/item.model'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; /** * A private DataService implementation to delegate specific methods to. */ class ResearcherProfileServiceImpl extends DataService { - protected linkPath = 'profiles'; + protected linkPath = 'profiles'; - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } } @@ -60,100 +56,104 @@ class ResearcherProfileServiceImpl extends DataService { @dataService(RESEARCHER_PROFILE) export class ResearcherProfileService { - dataService: ResearcherProfileServiceImpl; + protected dataService: ResearcherProfileServiceImpl; - responseMsToLive: number = 10 * 1000; + protected responseMsToLive: number = 10 * 1000; - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected router: Router, - protected comparator: DefaultChangeAnalyzer, - protected itemService: ItemDataService, - protected configurationService: ConfigurationDataService ) { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected router: Router, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService) { - this.dataService = new ResearcherProfileServiceImpl(requestService, rdbService, store, objectCache, halService, - notificationsService, http, comparator); + this.dataService = new ResearcherProfileServiceImpl(requestService, rdbService, null, objectCache, halService, + notificationsService, http, comparator); + } + + /** + * Find the researcher profile with the given uuid. + * + * @param uuid the profile uuid + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public findById(uuid: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(uuid, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + + /** + * Create a new researcher profile for the current user. + */ + public create(): Observable> { + return this.dataService.create(new ResearcherProfile()); + } + + /** + * Delete a researcher profile. + * + * @param researcherProfile the profile to delete + */ + public delete(researcherProfile: ResearcherProfile): Observable { + return this.dataService.delete(researcherProfile.id).pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => response.isSuccess) + ); + } + + /** + * Find a researcher profile by its own related item + * + * @param item + */ + public findByRelatedItem(item: Item): Observable> { + const profileId = item.firstMetadata('dspace.object.owner')?.authority; + if (isEmpty(profileId)) { + return createFailedRemoteDataObject$(); + } else { + return this.findById(profileId); } + } - /** - * Find the researcher profile with the given uuid. - * - * @param uuid the profile uuid - */ - findById(uuid: string): Observable { - return this.dataService.findById(uuid, false) - .pipe ( getFinishedRemoteData(), - map((remoteData) => remoteData.payload)); - } + /** + * Find the item id related to the given researcher profile. + * + * @param researcherProfile the profile to find for + */ + public findRelatedItemId(researcherProfile: ResearcherProfile): Observable { + const relatedItem$ = researcherProfile.item ? researcherProfile.item : this.itemService.findByHref(researcherProfile._links.item.href, false); + return relatedItem$.pipe( + getFirstCompletedRemoteData(), + map((itemRD: RemoteData) => (itemRD.hasSucceeded && itemRD.payload) ? itemRD.payload.id : null) + ); + } - /** - * Create a new researcher profile for the current user. - */ - create(): Observable> { - return this.dataService.create( new ResearcherProfile()); - } + /** + * Change the visibility of the given researcher profile setting the given value. + * + * @param researcherProfile the profile to update + * @param visible the visibility value to set + */ + public setVisibility(researcherProfile: ResearcherProfile, visible: boolean): Observable> { + const replaceOperation: ReplaceOperation = { + path: '/visible', + op: 'replace', + value: visible + }; - /** - * Delete a researcher profile. - * - * @param researcherProfile the profile to delete - */ - delete(researcherProfile: ResearcherProfile): Observable { - return this.dataService.delete(researcherProfile.id).pipe( - getFirstCompletedRemoteData(), - tap((response: RemoteData) => { - if (response.isSuccess) { - this.requestService.setStaleByHrefSubstring(researcherProfile._links.self.href); - } - }), - map((response: RemoteData) => response.isSuccess) - ); - } - - /** - * Find the item id related to the given researcher profile. - * - * @param researcherProfile the profile to find for - */ - findRelatedItemId( researcherProfile: ResearcherProfile ): Observable { - return this.itemService.findByHref(researcherProfile._links.item.href, false) - .pipe (getFirstSucceededRemoteDataPayload(), - catchError((error) => { - console.debug(error); - return observableOf(null); - }), - map((item) => item != null ? item.id : null )); - } - - /** - * Change the visibility of the given researcher profile setting the given value. - * - * @param researcherProfile the profile to update - * @param visible the visibility value to set - */ - setVisibility(researcherProfile: ResearcherProfile, visible: boolean): Observable { - - const replaceOperation: ReplaceOperation = { - path: '/visible', - op: 'replace', - value: visible - }; - - return this.patch(researcherProfile, [replaceOperation]).pipe ( - switchMap( ( ) => this.findById(researcherProfile.id)) - ); - } - - patch(researcherProfile: ResearcherProfile, operations: Operation[]): Observable> { - return this.dataService.patch(researcherProfile, operations); - } + return this.dataService.patch(researcherProfile, [replaceOperation]); + } /** * Creates a researcher profile starting from an external source URI @@ -170,13 +170,25 @@ export class ResearcherProfileService { href$.pipe( find((href: string) => hasValue(href)), - map((href: string) => { - const request = new PostRequest(requestId, href, sourceUri, options); - this.requestService.send(request); - }) - ).subscribe(); + map((href: string) => this.dataService.buildHrefWithParams(href, [], followLink('item'))) + ).subscribe((endpoint: string) => { + const request = new PostRequest(requestId, endpoint, sourceUri, options); + this.requestService.send(request); + }); - return this.rdbService.buildFromRequestUUID(requestId); + return this.rdbService.buildFromRequestUUID(requestId, followLink('item')); } + /** + * Update researcher profile by patch orcid operation + * + * @param researcherProfile + * @param operations + */ + public updateByOrcidOperations(researcherProfile: ResearcherProfile, operations: Operation[]): Observable> { + return this.dataService.patch(researcherProfile, operations); + } + + + } diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index db52a0547e..e9dfbe7e2c 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -386,6 +386,10 @@ describe('RegistryService', () => { result = registryService.deleteMetadataSchema(mockSchemasList[0].id); }); + it('should defer to MetadataSchemaDataService.delete', () => { + expect(metadataSchemaService.delete).toHaveBeenCalledWith(`${mockSchemasList[0].id}`); + }); + it('should return a successful response', () => { result.subscribe((response: RemoteData) => { expect(response.hasSucceeded).toBe(true); @@ -400,6 +404,10 @@ describe('RegistryService', () => { result = registryService.deleteMetadataField(mockFieldsList[0].id); }); + it('should defer to MetadataFieldDataService.delete', () => { + expect(metadataFieldService.delete).toHaveBeenCalledWith(`${mockFieldsList[0].id}`); + }); + it('should return a successful response', () => { result.subscribe((response: RemoteData) => { expect(response.hasSucceeded).toBe(true); diff --git a/src/app/core/reload/reload.guard.spec.ts b/src/app/core/reload/reload.guard.spec.ts index 317245bafa..6146f51572 100644 --- a/src/app/core/reload/reload.guard.spec.ts +++ b/src/app/core/reload/reload.guard.spec.ts @@ -1,13 +1,17 @@ -import { ReloadGuard } from './reload.guard'; import { Router } from '@angular/router'; +import { AppConfig } from '../../../config/app-config.interface'; +import { DefaultAppConfig } from '../../../config/default-app-config'; +import { ReloadGuard } from './reload.guard'; describe('ReloadGuard', () => { let guard: ReloadGuard; let router: Router; + let appConfig: AppConfig; beforeEach(() => { router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']); - guard = new ReloadGuard(router); + appConfig = new DefaultAppConfig(); + guard = new ReloadGuard(router, appConfig); }); describe('canActivate', () => { @@ -27,7 +31,7 @@ describe('ReloadGuard', () => { it('should create a UrlTree with the redirect URL', () => { guard.canActivate(route, undefined); - expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl); + expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl.substring(1)); }); }); diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts index 78f9dcf642..1e99a5687a 100644 --- a/src/app/core/reload/reload.guard.ts +++ b/src/app/core/reload/reload.guard.ts @@ -1,5 +1,6 @@ +import { Inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { Injectable } from '@angular/core'; +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; /** @@ -8,7 +9,10 @@ import { isNotEmpty } from '../../shared/empty.util'; */ @Injectable() export class ReloadGuard implements CanActivate { - constructor(private router: Router) { + constructor( + private router: Router, + @Inject(APP_CONFIG) private appConfig: AppConfig, + ) { } /** @@ -18,7 +22,10 @@ export class ReloadGuard implements CanActivate { */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { if (isNotEmpty(route.queryParams.redirect)) { - return this.router.parseUrl(route.queryParams.redirect); + const url = route.queryParams.redirect.startsWith(this.appConfig.ui.nameSpace) + ? route.queryParams.redirect.substring(this.appConfig.ui.nameSpace.length) + : route.queryParams.redirect; + return this.router.parseUrl(url); } else { return this.router.createUrlTree(['home']); } diff --git a/src/app/core/resource-policy/resource-policy.service.spec.ts b/src/app/core/resource-policy/resource-policy.service.spec.ts index 59316c0098..b788a16520 100644 --- a/src/app/core/resource-policy/resource-policy.service.spec.ts +++ b/src/app/core/resource-policy/resource-policy.service.spec.ts @@ -19,6 +19,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils import { RestResponse } from '../cache/response.models'; import { RequestEntry } from '../data/request-entry.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { GroupDataService } from '../eperson/group-data.service'; describe('ResourcePolicyService', () => { let scheduler: TestScheduler; @@ -28,6 +30,8 @@ describe('ResourcePolicyService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; + let ePersonService: EPersonDataService; + let groupService: GroupDataService; const resourcePolicy: any = { id: '1', @@ -88,6 +92,8 @@ describe('ResourcePolicyService', () => { const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const ePersonEndpoint = 'EPERSON_EP'; + beforeEach(() => { scheduler = getTestScheduler(); @@ -105,6 +111,7 @@ describe('ResourcePolicyService', () => { removeByHrefSubstring: {}, getByHref: observableOf(responseCacheEntry), getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: {}, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { @@ -117,6 +124,11 @@ describe('ResourcePolicyService', () => { a: resourcePolicyRD }) }); + ePersonService = jasmine.createSpyObj('ePersonService', { + getBrowseEndpoint: hot('a', { + a: ePersonEndpoint + }), + }); objectCache = {} as ObjectCacheService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; @@ -129,7 +141,9 @@ describe('ResourcePolicyService', () => { halService, notificationsService, http, - comparator + comparator, + ePersonService, + groupService ); spyOn((service as any).dataService, 'create').and.callThrough(); @@ -320,4 +334,17 @@ describe('ResourcePolicyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('updateTarget', () => { + it('should create a new PUT request for eperson', () => { + const targetType = 'eperson'; + + const result = service.updateTarget(resourcePolicyId, requestURL, epersonUUID, targetType); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + }); diff --git a/src/app/core/resource-policy/resource-policy.service.ts b/src/app/core/resource-policy/resource-policy.service.ts index 065e58c53d..b0b7a6bd97 100644 --- a/src/app/core/resource-policy/resource-policy.service.ts +++ b/src/app/core/resource-policy/resource-policy.service.ts @@ -1,6 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; @@ -23,11 +23,19 @@ import { PaginatedList } from '../data/paginated-list.model'; import { ActionType } from './models/action-type.model'; import { RequestParam } from '../cache/models/request-param.model'; import { isNotEmpty } from '../../shared/empty.util'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { PutRequest } from '../data/request.models'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from '../data/parsing.service'; +import { StatusCodeOnlyResponseParsingService } from '../data/status-code-only-response-parsing.service'; +import { HALLink } from '../shared/hal-link.model'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { GroupDataService } from '../eperson/group-data.service'; /** @@ -44,7 +52,8 @@ class DataServiceImpl extends DataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer) { + protected comparator: ChangeAnalyzer, + ) { super(); } @@ -68,7 +77,10 @@ export class ResourcePolicyService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { + protected comparator: DefaultChangeAnalyzer, + protected ePersonService: EPersonDataService, + protected groupService: GroupDataService, + ) { this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); } @@ -221,4 +233,44 @@ export class ResourcePolicyService { return this.dataService.searchBy(this.searchByResourceMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Update the target of the resource policy + * @param resourcePolicyId the ID of the resource policy + * @param resourcePolicyHref the link to the resource policy + * @param targetUUID the UUID of the target to which the permission is being granted + * @param targetType the type of the target (eperson or group) to which the permission is being granted + */ + updateTarget(resourcePolicyId: string, resourcePolicyHref: string, targetUUID: string, targetType: string): Observable> { + + const targetService = targetType === 'eperson' ? this.ePersonService : this.groupService; + + const targetEndpoint$ = targetService.getBrowseEndpoint().pipe( + take(1), + map((endpoint: string) =>`${endpoint}/${targetUUID}`), + ); + + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + + this.requestService.setStaleByHrefSubstring(`${this.dataService.getLinkPath()}/${resourcePolicyId}/${targetType}`); + + targetEndpoint$.subscribe((targetEndpoint) => { + const resourceEndpoint = resourcePolicyHref + '/' + targetType; + const request = new PutRequest(requestId, resourceEndpoint, targetEndpoint, options); + Object.assign(request, { + getResponseParser(): GenericConstructor { + return StatusCodeOnlyResponseParsingService; + } + }); + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + + } + } diff --git a/src/app/core/services/api.service.ts b/src/app/core/services/api.service.ts deleted file mode 100644 index 4f8474e0c1..0000000000 --- a/src/app/core/services/api.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { throwError as observableThrowError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -@Injectable() -export class ApiService { - constructor(public _http: HttpClient) { - - } - - /** - * whatever domain/feature method name - */ - get(url: string, options?: any) { - return this._http.get(url, options).pipe( - catchError((err) => { - console.log('Error: ', err); - return observableThrowError(err); - })); - } - -} diff --git a/src/app/core/services/link-head.service.spec.ts b/src/app/core/services/link-head.service.spec.ts new file mode 100644 index 0000000000..017fe6af03 --- /dev/null +++ b/src/app/core/services/link-head.service.spec.ts @@ -0,0 +1,45 @@ +import { DOCUMENT } from '@angular/common'; +import { Renderer2, RendererFactory2 } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; +import { LinkHeadService } from './link-head.service'; + +describe('LinkHeadService', () => { + + let service: LinkHeadService; + + const renderer2: Renderer2 = { + createRenderer: jasmine.createSpy('createRenderer'), + createElement: jasmine.createSpy('createElement'), + setAttribute: jasmine.createSpy('setAttribute'), + appendChild: jasmine.createSpy('appendChild') + } as unknown as Renderer2; + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + providers: [ + MockProvider(RendererFactory2, { + createRenderer: () => renderer2 + }), + { provide: Document, useExisting: DOCUMENT }, + ] + }); + })); + + beforeEach(() => { + service = new LinkHeadService(TestBed.inject(RendererFactory2), TestBed.inject(DOCUMENT)); + }); + + describe('link', () => { + it('should create a link tag', () => { + const link = service.addTag({ + href: 'test', + type: 'application/atom+xml', + rel: 'alternate', + title: 'Sitewide Atom feed' + }); + expect(link).not.toBeUndefined(); + }); + }); + +}); diff --git a/src/app/core/services/link-head.service.ts b/src/app/core/services/link-head.service.ts new file mode 100644 index 0000000000..d608618ca4 --- /dev/null +++ b/src/app/core/services/link-head.service.ts @@ -0,0 +1,90 @@ +import { Injectable, RendererFactory2, ViewEncapsulation, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * LinkHead Service injects tag into the head element during runtime. + */ +@Injectable() +export class LinkHeadService { + constructor( + private rendererFactory: RendererFactory2, + @Inject(DOCUMENT) private document + ) { + + } + + /** + * Method to create a Link tag in the HEAD of the html. + * @param tag LinkDefition is the paramaters to define a link tag. + * @returns Link tag that was created + */ + addTag(tag: LinkDefinition) { + + try { + const renderer = this.rendererFactory.createRenderer(this.document, { + id: '-1', + encapsulation: ViewEncapsulation.None, + styles: [], + data: {} + }); + + const link = renderer.createElement('link'); + const head = this.document.head; + + if (head === null) { + throw new Error(' not found within DOCUMENT.'); + } + + Object.keys(tag).forEach((prop: string) => { + return renderer.setAttribute(link, prop, tag[prop]); + }); + + renderer.appendChild(head, link); + return renderer; + } catch (e) { + console.error('Error within linkService : ', e); + } + } + + /** + * Removes a link tag in header based on the given attrSelector. + * @param attrSelector The attr assigned to a link tag which will be used to determine what link to remove. + */ + removeTag(attrSelector: string) { + if (attrSelector) { + try { + const renderer = this.rendererFactory.createRenderer(this.document, { + id: '-1', + encapsulation: ViewEncapsulation.None, + styles: [], + data: {} + }); + const head = this.document.head; + if (head === null) { + throw new Error(' not found within DOCUMENT.'); + } + const linkTags = this.document.querySelectorAll('link[' + attrSelector + ']'); + for (const link of linkTags) { + renderer.removeChild(head, link); + } + } catch (e) { + console.log('Error while removing tag ' + e.message); + } + } + } +} + +export declare type LinkDefinition = { + charset?: string; + crossorigin?: string; + href?: string; + hreflang?: string; + media?: string; + rel?: string; + rev?: string; + sizes?: string; + target?: string; + type?: string; +} & { + [prop: string]: string; + }; diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index b29b8f662e..78a296496a 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -3,7 +3,7 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from './hal-endpoint.service'; import { EndpointMapRequest } from '../data/request.models'; -import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { environment } from '../../../environments/environment'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -162,9 +162,9 @@ describe('HALEndpointService', () => { return observableOf(endpointMaps[param]); }); - observableCombineLatest([ + observableCombineLatest([ (service as any).getEndpointAt(start, 'one'), - (service as any).getEndpointAt(start, 'one', 'two') + (service as any).getEndpointAt(start, 'one', 'two'), ]).subscribe(([endpoint1, endpoint2]) => { expect(endpoint1).toEqual(one); expect(endpoint2).toEqual(two); diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index d98c22225e..49ca7750b4 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -21,6 +21,8 @@ import { Version } from './version.model'; import { VERSION } from './version.resource-type'; import { BITSTREAM } from './bitstream.resource-type'; import { Bitstream } from './bitstream.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; /** * Class representing a DSpace Item @@ -72,6 +74,7 @@ export class Item extends DSpaceObject implements ChildHALResource { templateItemOf: HALLink; version: HALLink; thumbnail: HALLink; + accessStatus: HALLink; self: HALLink; }; @@ -110,6 +113,13 @@ export class Item extends DSpaceObject implements ChildHALResource { @link(BITSTREAM, false, 'thumbnail') thumbnail?: Observable>; + /** + * The access status for this Item + * Will be undefined unless the access status {@link HALLink} has been resolved. + */ + @link(ACCESS_STATUS) + accessStatus?: Observable>; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index b2ceaa4964..32610c82fd 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,5 +1,5 @@ -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { debounceTime, filter, find, map, switchMap, take, takeWhile } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, interval } from 'rxjs'; +import { filter, find, map, switchMap, take, takeWhile, debounce, debounceTime } from 'rxjs/operators'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/models/search-result.model'; import { PaginatedList } from '../data/paginated-list.model'; @@ -9,6 +9,17 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { InjectionToken } from '@angular/core'; +import { MonoTypeOperatorFunction, SchedulerLike } from 'rxjs/internal/types'; + +/** + * Use this method instead of the RxJs debounceTime if you're waiting for debouncing in tests; + * debounceTime doesn't work with fakeAsync/tick anymore as of Angular 13.2.6 & RxJs 7.5.5 + * Workaround suggested in https://github.com/angular/angular/issues/44351#issuecomment-1107454054 + * todo: remove once the above issue is fixed + */ +export const debounceTimeWorkaround = (dueTime: number, scheduler?: SchedulerLike): MonoTypeOperatorFunction => { + return debounce(() => interval(dueTime, scheduler)); +}; export const DEBOUNCE_TIME_OPERATOR = new InjectionToken<(dueTime: number) => (source: Observable) => Observable>('debounceTime', { providedIn: 'root', diff --git a/src/app/core/submission/models/sherpa-policies-details.model.ts b/src/app/core/submission/models/sherpa-policies-details.model.ts new file mode 100644 index 0000000000..af4d4a5890 --- /dev/null +++ b/src/app/core/submission/models/sherpa-policies-details.model.ts @@ -0,0 +1,89 @@ +/** + * An interface to represent an access condition. + */ +export class SherpaPoliciesDetailsObject { + + /** + * The sherpa policies error + */ + error: boolean; + + /** + * The sherpa policies journal details + */ + journals: Journal[]; + + /** + * The sherpa policies message + */ + message: string; + + /** + * The sherpa policies metadata + */ + metadata: Metadata; + +} + + +export interface Metadata { + id: number; + uri: string; + dateCreated: string; + dateModified: string; + inDOAJ: boolean; + publiclyVisible: boolean; +} + + +export interface Journal { + titles: string[]; + url: string; + issns: string[]; + romeoPub: string; + zetoPub: string; + inDOAJ: boolean; + publisher: Publisher; + publishers: Publisher[]; + policies: Policy[]; +} + +export interface Publisher { + name: string; + relationshipType: string; + country: string; + uri: string; + identifier: string; + paidAccessDescription: string; + paidAccessUrl: string; + publicationCount: number; +} + +export interface Policy { + id: number; + openAccessPermitted: boolean; + uri: string; + internalMoniker: string; + permittedVersions: PermittedVersions[]; + urls: any; + publicationCount: number; + preArchiving: string; + postArchiving: string; + pubArchiving: string; + openAccessProhibited: boolean; +} + +export interface PermittedVersions { + articleVersion: string; + option: number; + conditions: string[]; + prerequisites: string[]; + locations: string[]; + licenses: string[]; + embargo: Embargo; +} + +export interface Embargo { + units: any; + amount: any; +} diff --git a/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts b/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts new file mode 100644 index 0000000000..c57beadbb9 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts @@ -0,0 +1,22 @@ +import { SherpaPoliciesDetailsObject } from './sherpa-policies-details.model'; + +/** + * An interface to represent the submission's item accesses condition. + */ +export interface WorkspaceitemSectionSherpaPoliciesObject { + + /** + * The access condition id + */ + id: string; + + /** + * The sherpa policies retrievalTime + */ + retrievalTime: string; + + /** + * The sherpa policies details + */ + sherpaResponse: SherpaPoliciesDetailsObject; +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index 084da3f088..1112d740ed 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -3,6 +3,7 @@ import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.mod import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; +import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; /** * An interface to represent submission's section object. @@ -21,4 +22,5 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionLicenseObject | WorkspaceitemSectionCcLicenseObject | WorkspaceitemSectionAccessesObject + | WorkspaceitemSectionSherpaPoliciesObject | string; diff --git a/src/app/core/submission/resolver/submission-object.resolver.ts b/src/app/core/submission/resolver/submission-object.resolver.ts new file mode 100644 index 0000000000..32f6c544e2 --- /dev/null +++ b/src/app/core/submission/resolver/submission-object.resolver.ts @@ -0,0 +1,43 @@ +import { DSpaceObject } from './../../shared/dspace-object.model'; +import { followLink } from './../../../shared/utils/follow-link-config.model'; +import { ChildHALResource } from './../../shared/child-hal-resource.model'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { switchMap } from 'rxjs/operators'; +import { DataService } from '../../data/data.service'; +import { RemoteData } from '../../data/remote-data'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class SubmissionObjectResolver implements Resolve> { + constructor( + protected dataService: DataService, + protected store: Store + ) { + } + + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const itemRD$ = this.dataService.findById(route.params.id, + true, + false, + followLink('item'), + ).pipe( + getFirstCompletedRemoteData(), + switchMap((wfiRD: RemoteData) => wfiRD.payload.item as Observable>), + getFirstCompletedRemoteData() + ); + return itemRD$; + } +} diff --git a/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.spec.ts b/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.spec.ts new file mode 100644 index 0000000000..3734ae0dd2 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.spec.ts @@ -0,0 +1,246 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceEventRestService } from './quality-assurance-event-rest.service'; +import { + qualityAssuranceEventObjectMissingPid, + qualityAssuranceEventObjectMissingPid2, + qualityAssuranceEventObjectMissingProjectFound +} from '../../../../shared/mocks/notifications.mock'; +import { ReplaceOperation } from 'fast-json-patch'; +import {RequestEntry} from '../../../data/request-entry.model'; +import {FindListOptions} from '../../../data/find-list-options.model'; + +describe('QualityAssuranceEventRestService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceEventRestService; + let serviceASAny: any; + let responseCacheEntry: RequestEntry; + let responseCacheEntryB: RequestEntry; + let responseCacheEntryC: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qatopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + const topic = 'ENRICH!MORE!PID'; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceEventObjectMissingPid, qualityAssuranceEventObjectMissingPid2 ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaEventObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid); + const qaEventObjectMissingProjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + const status = 'ACCEPTED'; + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: jasmine.createSpy('getByHref'), + getByUUID: jasmine.createSpy('getByUUID') + }); + + responseCacheEntryB = new RequestEntry(); + responseCacheEntryB.request = { href: 'https://rest.api/' } as any; + responseCacheEntryB.response = new RestResponse(true, 201, 'Created'); + + responseCacheEntryC = new RequestEntry(); + responseCacheEntryC.request = { href: 'https://rest.api/' } as any; + responseCacheEntryC.response = new RestResponse(true, 204, 'No Content'); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaEventObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID') + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceEventRestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + + serviceASAny = service; + + spyOn(serviceASAny.dataService, 'searchBy').and.callThrough(); + spyOn(serviceASAny.dataService, 'findById').and.callThrough(); + spyOn(serviceASAny.dataService, 'patch').and.callThrough(); + spyOn(serviceASAny.dataService, 'postOnRelated').and.callThrough(); + spyOn(serviceASAny.dataService, 'deleteOnRelated').and.callThrough(); + }); + + describe('getEventsByTopic', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to dataservice.searchBy', () => { + const options: FindListOptions = { + searchParams: [ + { + fieldName: 'topic', + fieldValue: topic + } + ] + }; + service.getEventsByTopic(topic); + expect(serviceASAny.dataService.searchBy).toHaveBeenCalledWith('findByTopic', options, true, true); + }); + + it('should return a RemoteData> for the object with the given Topic', () => { + const result = service.getEventsByTopic(topic); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to dataservice.findById', () => { + service.getEvent(qualityAssuranceEventObjectMissingPid.id).subscribe( + (res) => { + expect(serviceASAny.dataService.findById).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid.id, true, true); + } + ); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getEvent(qualityAssuranceEventObjectMissingPid.id); + const expected = cold('(a)', { + a: qaEventObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('patchEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to dataservice.patch', () => { + service.patchEvent(status, qualityAssuranceEventObjectMissingPid).subscribe( + (res) => { + expect(serviceASAny.dataService.patch).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid, operation); + } + ); + }); + + it('should return a RemoteData with HTTP 200', () => { + const result = service.patchEvent(status, qualityAssuranceEventObjectMissingPid); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('boundProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectMissingProjectRD)); + }); + + it('should proxy the call to dataservice.postOnRelated', () => { + service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID).subscribe( + (res) => { + expect(serviceASAny.dataService.postOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + } + ); + }); + + it('should return a RestResponse with HTTP 201', () => { + const result = service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('removeProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(createSuccessfulRemoteDataObject({}))); + }); + + it('should proxy the call to dataservice.deleteOnRelated', () => { + service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id).subscribe( + (res) => { + expect(serviceASAny.dataService.deleteOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id); + } + ); + }); + + it('should return a RestResponse with HTTP 204', () => { + const result = service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject({}) + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.ts b/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.ts new file mode 100644 index 0000000000..a7cdd9e786 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/events/quality-assurance-event-rest.service.ts @@ -0,0 +1,185 @@ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { RestResponse } from '../../../cache/response.models'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../cache/builders/build-decorators'; +import { RequestService } from '../../../data/request.service'; +import { DataService } from '../../../data/data.service'; +import { ChangeAnalyzer } from '../../../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceEventObject } from '../models/quality-assurance-event.model'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from '../models/quality-assurance-event-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { ReplaceOperation } from 'fast-json-patch'; +import { NoContent } from '../../../shared/NoContent.model'; +import {CoreState} from '../../../core-state.model'; +import {FindListOptions} from '../../../data/find-list-options.model'; + + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + /** + * The REST endpoint. + */ + protected linkPath = 'qaevents'; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {ChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } +} + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_EVENT_OBJECT) +export class QualityAssuranceEventRestService { + /** + * A private DataService implementation to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DefaultChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Return the list of Quality Assurance events by topic. + * + * @param topic + * The Quality Assurance topic + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Quality Assurance events. + */ + public getEventsByTopic(topic: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + options.searchParams = [ + { + fieldName: 'topic', + fieldValue: topic + } + ]; + return this.dataService.searchBy('findByTopic', options, true, true, ...linksToFollow); + } + + /** + * Clear findByTopic requests from cache + */ + public clearFindByTopicRequests() { + this.requestService.removeByHrefSubstring('findByTopic'); + } + + /** + * Return a single Quality Assurance event. + * + * @param id + * The Quality Assurance event id + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return Observable> + * The Quality Assurance event. + */ + public getEvent(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, true, true, ...linksToFollow); + } + + /** + * Save the new status of a Quality Assurance event. + * + * @param status + * The new status + * @param dso QualityAssuranceEventObject + * The event item + * @param reason + * The optional reason (not used for now; for future implementation) + * @return Observable + * The REST response. + */ + public patchEvent(status, dso, reason?: string): Observable> { + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + return this.dataService.patch(dso, operation); + } + + /** + * Bound a project to a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @param projectId + * The project Id to bound + * @return Observable + * The REST response. + */ + public boundProject(itemId: string, projectId: string): Observable> { + return this.dataService.postOnRelated(itemId, projectId); + } + + /** + * Remove a project from a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @return Observable + * The REST response. + */ + public removeProject(itemId: string): Observable> { + return this.dataService.deleteOnRelated(itemId); + } +} diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-event-object.resource-type.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-event-object.resource-type.ts new file mode 100644 index 0000000000..2dedc84d08 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-event-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance event + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_EVENT_OBJECT = new ResourceType('qaevent'); diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-event.model.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-event.model.ts new file mode 100644 index 0000000000..c9395bd528 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-event.model.ts @@ -0,0 +1,166 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from './quality-assurance-event-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { Item } from '../../../shared/item.model'; +import { ITEM } from '../../../shared/item.resource-type'; +import { link, typedObject } from '../../../cache/builders/build-decorators'; +import { RemoteData } from '../../../data/remote-data'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance event message + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface QualityAssuranceEventMessageObject { + +} + +/** + * The interface representing the Quality Assurance event message + */ +export interface OpenaireQualityAssuranceEventMessageObject { + /** + * The type of 'value' + */ + type: string; + + /** + * The value suggested by Notifications + */ + value: string; + + /** + * The abstract suggested by Notifications + */ + abstract: string; + + /** + * The project acronym suggested by Notifications + */ + acronym: string; + + /** + * The project code suggested by Notifications + */ + code: string; + + /** + * The project funder suggested by Notifications + */ + funder: string; + + /** + * The project program suggested by Notifications + */ + fundingProgram?: string; + + /** + * The project jurisdiction suggested by Notifications + */ + jurisdiction: string; + + /** + * The project title suggested by Notifications + */ + title: string; + + /** + * The OPENAIRE ID. + */ + openaireId: string; + +} + +/** + * The interface representing the Quality Assurance event model + */ +@typedObject +export class QualityAssuranceEventObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_EVENT_OBJECT; + + /** + * The Quality Assurance event uuid inside DSpace + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this Quality Assurance event + */ + @autoserializeAs(String, 'id') + uuid: string; + + /** + * The Quality Assurance event original id (ex.: the source archive OAI-PMH identifier) + */ + @autoserialize + originalId: string; + + /** + * The title of the article to which the suggestion refers + */ + @autoserialize + title: string; + + /** + * Reliability of the suggestion (of the data inside 'message') + */ + @autoserialize + trust: number; + + /** + * The timestamp Quality Assurance event was saved in DSpace + */ + @autoserialize + eventDate: string; + + /** + * The Quality Assurance event status (ACCEPTED, REJECTED, DISCARDED, PENDING) + */ + @autoserialize + status: string; + + /** + * The suggestion data. Data may vary depending on the source + */ + @autoserialize + message: OpenaireQualityAssuranceEventMessageObject; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + target: HALLink, + related: HALLink + }; + + /** + * The related publication DSpace item + * Will be undefined unless the {@item HALLink} has been resolved. + */ + @link(ITEM) + target?: Observable>; + + /** + * The related project for this Event + * Will be undefined unless the {@related HALLink} has been resolved. + */ + @link(ITEM) + related?: Observable>; +} diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-source-object.resource-type.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-source-object.resource-type.ts new file mode 100644 index 0000000000..5f4c8dd954 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-source-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance source + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_SOURCE_OBJECT = new ResourceType('qasource'); diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-source.model.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-source.model.ts new file mode 100644 index 0000000000..f59467384f --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-source.model.ts @@ -0,0 +1,52 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './quality-assurance-source-object.resource-type'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance source model + */ +@typedObject +export class QualityAssuranceSourceObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_SOURCE_OBJECT; + + /** + * The Quality Assurance source id + */ + @autoserialize + id: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this source + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic-object.resource-type.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic-object.resource-type.ts new file mode 100644 index 0000000000..7e12dd9ca8 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance topic + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_TOPIC_OBJECT = new ResourceType('qatopic'); diff --git a/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic.model.ts b/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic.model.ts new file mode 100644 index 0000000000..529980e5f7 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/models/quality-assurance-topic.model.ts @@ -0,0 +1,58 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './quality-assurance-topic-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance topic model + */ +@typedObject +export class QualityAssuranceTopicObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_TOPIC_OBJECT; + + /** + * The Quality Assurance topic id + */ + @autoserialize + id: string; + + /** + * The Quality Assurance topic name to display + */ + @autoserialize + name: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this topic + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.spec.ts b/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.spec.ts new file mode 100644 index 0000000000..d574b36802 --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.spec.ts @@ -0,0 +1,127 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import {RequestEntry} from '../../../data/request-entry.model'; +import {QualityAssuranceSourceRestService} from './quality-assurance-source-rest.service'; + +describe('QualityAssuranceSourceRestService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceSourceRestService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qasources'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaSourceObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceSourceObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaSourceObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceSourceRestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + + spyOn((service as any).dataService, 'findAllByHref').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + }); + + describe('getSources', () => { + it('should proxy the call to dataservice.findAllByHref', (done) => { + service.getSources().subscribe( + (res) => { + expect((service as any).dataService.findAllByHref).toHaveBeenCalledWith(endpointURL, {}, true, true); + } + ); + done(); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getSources(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getSource', () => { + it('should proxy the call to dataservice.findByHref', (done) => { + service.getSource(qualityAssuranceSourceObjectMorePid.id).subscribe( + (res) => { + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(endpointURL + '/' + qualityAssuranceSourceObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getSource(qualityAssuranceSourceObjectMorePid.id); + const expected = cold('(a)', { + a: qaSourceObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.ts b/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.ts new file mode 100644 index 0000000000..6b5ca806ff --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/source/quality-assurance-source-rest.service.ts @@ -0,0 +1,132 @@ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; +import { mergeMap, take } from 'rxjs/operators'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../cache/builders/build-decorators'; +import { RequestService } from '../../../data/request.service'; +import { DataService } from '../../../data/data.service'; +import { ChangeAnalyzer } from '../../../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceSourceObject } from '../models/quality-assurance-source.model'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from '../models/quality-assurance-source-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import {CoreState} from '../../../core-state.model'; +import {FindListOptions} from '../../../data/find-list-options.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + /** + * The REST endpoint. + */ + protected linkPath = 'qasources'; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {ChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } +} + +/** + * The service handling all Quality Assurance source REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_SOURCE_OBJECT) +export class QualityAssuranceSourceRestService { + /** + * A private DataService implementation to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DefaultChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Return the list of Quality Assurance source. + * + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Quality Assurance source. + */ + public getSources(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.getBrowseEndpoint(options, 'qasources').pipe( + take(1), + mergeMap((href: string) => this.dataService.findAllByHref(href, options, true, true, ...linksToFollow)), + ); + } + + /** + * Clear FindAll source requests from cache + */ + public clearFindAllSourceRequests() { + this.requestService.setStaleByHrefSubstring('qasources'); + } + + /** + * Return a single Quality Assurance source. + * + * @param id + * The Quality Assurance source id + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable> + * The Quality Assurance source. + */ + public getSource(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + const options = {}; + return this.dataService.getBrowseEndpoint(options, 'qasources').pipe( + take(1), + mergeMap((href: string) => this.dataService.findByHref(href + '/' + id, true, true, ...linksToFollow)) + ); + } +} diff --git a/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.spec.ts b/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.spec.ts new file mode 100644 index 0000000000..458bc4957d --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.spec.ts @@ -0,0 +1,127 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceTopicRestService } from './quality-assurance-topic-rest.service'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import {RequestEntry} from '../../../data/request-entry.model'; + +describe('QualityAssuranceTopicRestService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceTopicRestService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qatopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaTopicObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceTopicObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaTopicObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceTopicRestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + + spyOn((service as any).dataService, 'findAllByHref').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + }); + + describe('getTopics', () => { + it('should proxy the call to dataservice.findAllByHref', (done) => { + service.getTopics().subscribe( + (res) => { + expect((service as any).dataService.findAllByHref).toHaveBeenCalledWith(endpointURL, {}, true, true); + } + ); + done(); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getTopics(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getTopic', () => { + it('should proxy the call to dataservice.findByHref', (done) => { + service.getTopic(qualityAssuranceTopicObjectMorePid.id).subscribe( + (res) => { + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(endpointURL + '/' + qualityAssuranceTopicObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getTopic(qualityAssuranceTopicObjectMorePid.id); + const expected = cold('(a)', { + a: qaTopicObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.ts b/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.ts new file mode 100644 index 0000000000..a2f2498fca --- /dev/null +++ b/src/app/core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service.ts @@ -0,0 +1,132 @@ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; +import { mergeMap, take } from 'rxjs/operators'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../cache/builders/build-decorators'; +import { RequestService } from '../../../data/request.service'; +import { DataService } from '../../../data/data.service'; +import { ChangeAnalyzer } from '../../../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceTopicObject } from '../models/quality-assurance-topic.model'; +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from '../models/quality-assurance-topic-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import {CoreState} from '../../../core-state.model'; +import {FindListOptions} from '../../../data/find-list-options.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + /** + * The REST endpoint. + */ + protected linkPath = 'qatopics'; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {ChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } +} + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_TOPIC_OBJECT) +export class QualityAssuranceTopicRestService { + /** + * A private DataService implementation to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DefaultChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Return the list of Quality Assurance topics. + * + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Quality Assurance topics. + */ + public getTopics(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.getBrowseEndpoint(options, 'qatopics').pipe( + take(1), + mergeMap((href: string) => this.dataService.findAllByHref(href, options, true, true, ...linksToFollow)), + ); + } + + /** + * Clear FindAll topics requests from cache + */ + public clearFindAllTopicsRequests() { + this.requestService.setStaleByHrefSubstring('qatopics'); + } + + /** + * Return a single Quality Assurance topic. + * + * @param id + * The Quality Assurance topic id + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable> + * The Quality Assurance topic. + */ + public getTopic(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + const options = {}; + return this.dataService.getBrowseEndpoint(options, 'qatopics').pipe( + take(1), + mergeMap((href: string) => this.dataService.findByHref(href + '/' + id, true, true, ...linksToFollow)) + ); + } +} diff --git a/src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-objects.resource-type.ts b/src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-objects.resource-type.ts similarity index 100% rename from src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-objects.resource-type.ts rename to src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-objects.resource-type.ts diff --git a/src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-source.model.ts b/src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-source.model.ts similarity index 100% rename from src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-source.model.ts rename to src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-source.model.ts diff --git a/src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-target.model.ts b/src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model.ts similarity index 100% rename from src/app/core/openaire/reciter-suggestions/models/openaire-suggestion-target.model.ts rename to src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model.ts diff --git a/src/app/core/openaire/reciter-suggestions/models/openaire-suggestion.model.ts b/src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model.ts similarity index 100% rename from src/app/core/openaire/reciter-suggestions/models/openaire-suggestion.model.ts rename to src/app/core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model.ts diff --git a/src/app/core/openaire/reciter-suggestions/openaire-suggestions-data.service.ts b/src/app/core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service.ts similarity index 100% rename from src/app/core/openaire/reciter-suggestions/openaire-suggestions-data.service.ts rename to src/app/core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service.ts diff --git a/src/app/curation-form/curation-form.component.spec.ts b/src/app/curation-form/curation-form.component.spec.ts index 4ff013f77c..dc70b925e8 100644 --- a/src/app/curation-form/curation-form.component.spec.ts +++ b/src/app/curation-form/curation-form.component.spec.ts @@ -15,6 +15,7 @@ import { By } from '@angular/platform-browser'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; +import { HandleService } from '../shared/handle.service'; describe('CurationFormComponent', () => { let comp: CurationFormComponent; @@ -23,6 +24,7 @@ describe('CurationFormComponent', () => { let scriptDataService: ScriptDataService; let processDataService: ProcessDataService; let configurationDataService: ConfigurationDataService; + let handleService: HandleService; let notificationsService; let router; @@ -51,6 +53,10 @@ describe('CurationFormComponent', () => { })) }); + handleService = { + normalizeHandle: (a) => a + } as any; + notificationsService = new NotificationsServiceStub(); router = new RouterStub(); @@ -58,11 +64,12 @@ describe('CurationFormComponent', () => { imports: [TranslateModule.forRoot(), FormsModule, ReactiveFormsModule], declarations: [CurationFormComponent], providers: [ - {provide: ScriptDataService, useValue: scriptDataService}, - {provide: ProcessDataService, useValue: processDataService}, - {provide: NotificationsService, useValue: notificationsService}, - {provide: Router, useValue: router}, - {provide: ConfigurationDataService, useValue: configurationDataService}, + { provide: ScriptDataService, useValue: scriptDataService }, + { provide: ProcessDataService, useValue: processDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: HandleService, useValue: handleService }, + { provide: Router, useValue: router}, + { provide: ConfigurationDataService, useValue: configurationDataService }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -143,4 +150,13 @@ describe('CurationFormComponent', () => { {name: '-i', value: 'all'}, ], []); }); + + it(`should show an error notification and return when an invalid dsoHandle is provided`, () => { + comp.dsoHandle = 'test-handle'; + spyOn(handleService, 'normalizeHandle').and.returnValue(null); + comp.submit(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(scriptDataService.invoke).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/curation-form/curation-form.component.ts b/src/app/curation-form/curation-form.component.ts index 31501e70d7..422c955037 100644 --- a/src/app/curation-form/curation-form.component.ts +++ b/src/app/curation-form/curation-form.component.ts @@ -5,7 +5,7 @@ import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { find, map } from 'rxjs/operators'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { Router } from '@angular/router'; import { ProcessDataService } from '../core/data/processes/process-data.service'; @@ -14,9 +14,9 @@ import { ConfigurationDataService } from '../core/data/configuration-data.servic import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { Observable } from 'rxjs'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; +import { HandleService } from '../shared/handle.service'; export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; - /** * Component responsible for rendering the Curation Task form */ @@ -39,6 +39,7 @@ export class CurationFormComponent implements OnInit { private processDataService: ProcessDataService, private notificationsService: NotificationsService, private translateService: TranslateService, + private handleService: HandleService, private router: Router ) { } @@ -76,13 +77,19 @@ export class CurationFormComponent implements OnInit { const taskName = this.form.get('task').value; let handle; if (this.hasHandleValue()) { - handle = this.dsoHandle; + handle = this.handleService.normalizeHandle(this.dsoHandle); + if (isEmpty(handle)) { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.invalid-handle')); + return; + } } else { - handle = this.form.get('handle').value; + handle = this.handleService.normalizeHandle(this.form.get('handle').value); if (isEmpty(handle)) { handle = 'all'; } } + this.scriptDataService.invoke('curate', [ { name: '-t', value: taskName }, { name: '-i', value: handle }, diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 6e73935672..dbd9d03994 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -3,6 +3,9 @@ {{'journalissue.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index f96379dafd..f5e9dc9b2b 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalIssue', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Issue */ -export class JournalIssueComponent extends ItemComponent { +export class JournalIssueComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 5d4d8d06ce..8b19c37033 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -3,6 +3,9 @@ {{'journalvolume.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index eeb93e7070..cc09be7959 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalVolume', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Volume */ -export class JournalVolumeComponent extends ItemComponent { +export class JournalVolumeComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index d51c55e5d6..45cbc1f839 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -3,6 +3,9 @@ {{'journal.page.titleprefix' | translate}}
+
@@ -44,7 +47,8 @@
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index 0e756b7dc9..3ed73e7891 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -29,6 +29,11 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { JournalComponent } from './journal.component'; import { RouteService } from '../../../../core/services/route.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -65,12 +70,15 @@ describe('JournalComponent', () => { }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ { provide: ItemDataService, useValue: {} }, @@ -86,7 +94,11 @@ describe('JournalComponent', () => { { provide: DSOChangeAnalyzer, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: {} } ], diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index 3fe0903145..acfd31d8f6 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Journal', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal */ -export class JournalComponent extends ItemComponent { +export class JournalComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index fb0ad21b6e..40f837bcd1 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -7,13 +7,11 @@ class="lead" [innerHTML]="firstMetadataValue('organization.legalName')"> - - - - - - + + + + diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html index c8a9ea9e28..6d9cfe10c4 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html @@ -5,7 +5,7 @@ [innerHTML]="name"> + [innerHTML]="name">
+
@@ -54,12 +57,12 @@ [relationTypes]="[{ label: 'isOrgUnitOfPerson', filter: 'isOrgUnitOfPerson', - configuration: 'person' + configuration: 'person-relationships' }, { label: 'isOrgUnitOfProject', filter: 'isOrgUnitOfProject', - configuration: 'project' + configuration: 'project-relationships' }]">
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index ab756db562..cbf8497f35 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Organisation Unit */ -export class OrgUnitComponent extends ItemComponent { +export class OrgUnitComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 7505a31327..ace42f844e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -3,8 +3,12 @@ {{'person.page.titleprefix' | translate}}
+ + - +
@@ -20,18 +24,10 @@ [fields]="['person.email']" [label]="'person.page.email'"> - - - - - - - -
+ + diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts index 93b3cf208d..efbc48a209 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts @@ -17,24 +17,12 @@ const mockItem: Item = Object.assign(new Item(), { value: 'fake@email.com' } ], - // 'person.identifier.orcid': [ - // { - // language: 'en_US', - // value: 'ORCID-1' - // } - // ], 'person.birthDate': [ { language: 'en_US', value: '1993' } ], - // 'person.identifier.staffid': [ - // { - // language: 'en_US', - // value: '1' - // } - // ], 'person.jobTitle': [ { language: 'en_US', @@ -62,4 +50,42 @@ const mockItem: Item = Object.assign(new Item(), { } }); -describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent)); +const mockItemWithTitle: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: { + 'person.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.birthDate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Doe, John' + } + ] + }, + relationships: createRelationshipsObservable(), + _links: { + self : { + href: 'item-href' + } + } +}); + +describe('PersonComponent with family and given names', getItemPageFieldsTest(mockItem, PersonComponent)); + +describe('PersonComponent with dc.title', getItemPageFieldsTest(mockItemWithTitle, PersonComponent)); diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 42eb4ec7e6..8fde5ee69a 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -1,20 +1,8 @@ -import {Component, OnInit} from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; +import { Component } from '@angular/core'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; -import {MetadataValue} from '../../../../core/shared/metadata.models'; -import {FeatureID} from '../../../../core/data/feature-authorization/feature-id'; -import {mergeMap, take} from 'rxjs/operators'; -import {getFirstSucceededRemoteData} from '../../../../core/shared/operators'; -import {RemoteData} from '../../../../core/data/remote-data'; -import {ResearcherProfile} from '../../../../core/profile/model/researcher-profile.model'; -import {isNotUndefined} from '../../../../shared/empty.util'; -import {BehaviorSubject, Observable} from 'rxjs'; -import {RouteService} from '../../../../core/services/route.service'; -import {AuthorizationDataService} from '../../../../core/data/feature-authorization/authorization-data.service'; -import {ResearcherProfileService} from '../../../../core/profile/researcher-profile.service'; -import {NotificationsService} from '../../../../shared/notifications/notifications.service'; -import {TranslateService} from '@ngx-translate/core'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; @listableObjectComponent('Person', ViewMode.StandalonePage) @Component({ @@ -25,78 +13,23 @@ import {TranslateService} from '@ngx-translate/core'; /** * The component for displaying metadata and relations of an item of the type Person */ -export class PersonComponent extends ItemComponent implements OnInit { +export class PersonComponent extends VersionedItemComponent { - claimable$: BehaviorSubject = new BehaviorSubject(false); - - constructor(protected routeService: RouteService, - protected authorizationService: AuthorizationDataService, - protected notificationsService: NotificationsService, - protected translate: TranslateService, - protected researcherProfileService: ResearcherProfileService) { - super(routeService); - } - - ngOnInit(): void { - super.ngOnInit(); - - this.authorizationService.isAuthorized(FeatureID.ShowClaimItem, this.object._links.self.href).pipe( - take(1) - ).subscribe((isAuthorized: boolean) => { - this.claimable$.next(isAuthorized); - }); - - } - - claim() { - - this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.object._links.self.href).pipe( - take(1) - ).subscribe((isAuthorized: boolean) => { - if (!isAuthorized) { - this.notificationsService.warning(this.translate.get('researcherprofile.claim.not-authorized')); - } else { - this.createFromExternalSource(); - } - }); - - } - - createFromExternalSource() { - this.researcherProfileService.createFromExternalSource(this.object._links.self.href).pipe( - getFirstSucceededRemoteData(), - mergeMap((rd: RemoteData) => { - return this.researcherProfileService.findRelatedItemId(rd.payload); - })) - .subscribe((id: string) => { - if (isNotUndefined(id)) { - this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), - this.translate.get('researcherprofile.success.claim.body')); - this.claimable$.next(false); - } else { - this.notificationsService.error( - this.translate.get('researcherprofile.error.claim.title'), - this.translate.get('researcherprofile.error.claim.body')); - } - }); - } - - isClaimable(): Observable { - return this.claimable$; - } - - getTitleMetadataValues(): MetadataValue[]{ + /** + * Returns the metadata values to be used for the page title. + */ + getTitleMetadataValues(): MetadataValue[] { const metadataValues = []; const familyName = this.object?.firstMetadata('person.familyName'); const givenName = this.object?.firstMetadata('person.givenName'); const title = this.object?.firstMetadata('dc.title'); - if (familyName){ + if (familyName) { metadataValues.push(familyName); } - if (givenName){ + if (givenName) { metadataValues.push(givenName); } - if (metadataValues.length === 0 && title){ + if (metadataValues.length === 0 && title) { metadataValues.push(title); } return metadataValues; diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 7960631f3d..a068878fb4 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -3,6 +3,9 @@ {{'project.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index e53d8afd69..066427fc0d 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Project', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Project */ -export class ProjectComponent extends ItemComponent { +export class ProjectComponent extends VersionedItemComponent { } diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.html b/src/app/health-page/health-info/health-info-component/health-info-component.component.html new file mode 100644 index 0000000000..dbaaa7a6b6 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.html @@ -0,0 +1,27 @@ +
+
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+ +

{{ getPropertyLabel(entry.key) | titlecase }} : {{entry.value}}

+
+
diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.scss b/src/app/health-page/health-info/health-info-component/health-info-component.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts b/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts new file mode 100644 index 0000000000..b4532415b8 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthInfoComponentComponent } from './health-info-component.component'; +import { HealthInfoComponentOne, HealthInfoComponentTwo } from '../../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('HealthInfoComponentComponent', () => { + let component: HealthInfoComponentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbCollapseModule, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthInfoComponentComponent, + ObjNgFor + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthInfoComponentComponent); + component = fixture.componentInstance; + }); + + describe('when has nested components', () => { + beforeEach(() => { + component.healthInfoComponentName = 'App'; + component.healthInfoComponent = HealthInfoComponentOne; + component.isCollapsed = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display property', () => { + const properties = fixture.debugElement.queryAll(By.css('[data-test="property"]')); + expect(properties.length).toBe(14); + const components = fixture.debugElement.queryAll(By.css('[data-test="info-component"]')); + expect(components.length).toBe(4); + }); + + }); + + describe('when has plain properties', () => { + beforeEach(() => { + component.healthInfoComponentName = 'Java'; + component.healthInfoComponent = HealthInfoComponentTwo; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display property', () => { + const property = fixture.debugElement.queryAll(By.css('[data-test="property"]')); + expect(property.length).toBe(1); + }); + + }); +}); diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.ts b/src/app/health-page/health-info/health-info-component/health-info-component.component.ts new file mode 100644 index 0000000000..d2cb393f09 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.ts @@ -0,0 +1,46 @@ +import { Component, Input } from '@angular/core'; + +import { HealthInfoComponent } from '../../models/health-component.model'; +import { HealthComponentComponent } from '../../health-panel/health-component/health-component.component'; + +/** + * Shows a health info object + */ +@Component({ + selector: 'ds-health-info-component', + templateUrl: './health-info-component.component.html', + styleUrls: ['./health-info-component.component.scss'] +}) +export class HealthInfoComponentComponent extends HealthComponentComponent { + + /** + * The HealthInfoComponent object to display + */ + @Input() healthInfoComponent: HealthInfoComponent|string; + + /** + * The HealthInfoComponent object name + */ + @Input() healthInfoComponentName: string; + + /** + * A boolean representing if div should start collapsed + */ + @Input() isNested = false; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = false; + + /** + * Check if the HealthInfoComponent is has only string property or contains object + * + * @param entry The HealthInfoComponent to check + * @return boolean + */ + isPlainProperty(entry: HealthInfoComponent | string): boolean { + return typeof entry === 'string'; + } + +} diff --git a/src/app/health-page/health-info/health-info.component.html b/src/app/health-page/health-info/health-info.component.html new file mode 100644 index 0000000000..4bafcaa2d8 --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.html @@ -0,0 +1,25 @@ + + + + +
+ +
+ +
+ + +
+
+
+
+ + + +
+
+
diff --git a/src/app/health-page/health-info/health-info.component.scss b/src/app/health-page/health-info/health-info.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-info/health-info.component.spec.ts b/src/app/health-page/health-info/health-info.component.spec.ts new file mode 100644 index 0000000000..5a9b8bf0aa --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HealthInfoComponent } from './health-info.component'; +import { HealthInfoResponseObj } from '../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../shared/utils/object-ngfor.pipe'; +import { By } from '@angular/platform-browser'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; + +describe('HealthInfoComponent', () => { + let component: HealthInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthInfoComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthInfoComponent); + component = fixture.componentInstance; + component.healthInfoResponse = HealthInfoResponseObj; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create info component properly', () => { + const components = fixture.debugElement.queryAll(By.css('[data-test="info-component"]')); + expect(components.length).toBe(3); + }); +}); diff --git a/src/app/health-page/health-info/health-info.component.ts b/src/app/health-page/health-info/health-info.component.ts new file mode 100644 index 0000000000..186d00299c --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.ts @@ -0,0 +1,46 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthInfoResponse } from '../models/health-component.model'; + +/** + * A component to render a "health-info component" object. + * + * Note that the word "component" in "health-info component" doesn't refer to Angular use of the term + * but rather to the components used in the response of the health endpoint of Spring's Actuator + * API. + */ +@Component({ + selector: 'ds-health-info', + templateUrl: './health-info.component.html', + styleUrls: ['./health-info.component.scss'] +}) +export class HealthInfoComponent implements OnInit { + + @Input() healthInfoResponse: HealthInfoResponse; + + /** + * The first active panel id + */ + activeId: string; + + constructor(private translate: TranslateService) { + } + + ngOnInit(): void { + this.activeId = Object.keys(this.healthInfoResponse)[0]; + } + + /** + * Return translated label if exist for the given property + * + * @param panelKey + */ + public getPanelLabel(panelKey: string): string { + const translationKey = `health-page.section-info.${panelKey}.title`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? panelKey : translation; + } +} diff --git a/src/app/health-page/health-page.component.html b/src/app/health-page/health-page.component.html new file mode 100644 index 0000000000..8083389e1b --- /dev/null +++ b/src/app/health-page/health-page.component.html @@ -0,0 +1,27 @@ +
+ + diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss b/src/app/health-page/health-page.component.scss similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss rename to src/app/health-page/health-page.component.scss diff --git a/src/app/health-page/health-page.component.spec.ts b/src/app/health-page/health-page.component.spec.ts new file mode 100644 index 0000000000..f3847ab092 --- /dev/null +++ b/src/app/health-page/health-page.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; + +import { of } from 'rxjs'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { HealthPageComponent } from './health-page.component'; +import { HealthService } from './health.service'; +import { HealthInfoResponseObj, HealthResponseObj } from '../shared/mocks/health-endpoint.mocks'; +import { RawRestResponse } from '../core/dspace-rest/raw-rest-response.model'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; + +describe('HealthPageComponent', () => { + let component: HealthPageComponent; + let fixture: ComponentFixture; + + const healthService = jasmine.createSpyObj('healthDataService', { + getHealth: jasmine.createSpy('getHealth'), + getInfo: jasmine.createSpy('getInfo'), + }); + + const healthRestResponse$ = of({ + payload: HealthResponseObj, + statusCode: 200, + statusText: 'OK' + } as RawRestResponse); + + const healthInfoRestResponse$ = of({ + payload: HealthInfoResponseObj, + statusCode: 200, + statusText: 'OK' + } as RawRestResponse); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbNavModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ HealthPageComponent ], + providers: [ + { provide: HealthService, useValue: healthService } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthPageComponent); + component = fixture.componentInstance; + healthService.getHealth.and.returnValue(healthRestResponse$); + healthService.getInfo.and.returnValue(healthInfoRestResponse$); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create nav items properly', () => { + const navItems = fixture.debugElement.queryAll(By.css('li.nav-item')); + expect(navItems.length).toBe(2); + }); +}); diff --git a/src/app/health-page/health-page.component.ts b/src/app/health-page/health-page.component.ts new file mode 100644 index 0000000000..aa7bd7cba4 --- /dev/null +++ b/src/app/health-page/health-page.component.ts @@ -0,0 +1,66 @@ +import { Component, OnInit } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { HealthService } from './health.service'; +import { HealthInfoResponse, HealthResponse } from './models/health-component.model'; + +@Component({ + selector: 'ds-health-page', + templateUrl: './health-page.component.html', + styleUrls: ['./health-page.component.scss'] +}) +export class HealthPageComponent implements OnInit { + + /** + * Health info endpoint response + */ + healthInfoResponse: BehaviorSubject = new BehaviorSubject(null); + + /** + * Health endpoint response + */ + healthResponse: BehaviorSubject = new BehaviorSubject(null); + + /** + * Represent if the response from health status endpoint is already retrieved or not + */ + healthResponseInitialised: BehaviorSubject = new BehaviorSubject(false); + + /** + * Represent if the response from health info endpoint is already retrieved or not + */ + healthInfoResponseInitialised: BehaviorSubject = new BehaviorSubject(false); + + constructor(private healthDataService: HealthService) { + } + + /** + * Retrieve responses from rest + */ + ngOnInit(): void { + this.healthDataService.getHealth().pipe(take(1)).subscribe({ + next: (data: any) => { + this.healthResponse.next(data.payload); + this.healthResponseInitialised.next(true); + }, + error: () => { + this.healthResponse.next(null); + this.healthResponseInitialised.next(true); + } + }); + + this.healthDataService.getInfo().pipe(take(1)).subscribe({ + next: (data: any) => { + this.healthInfoResponse.next(data.payload); + this.healthInfoResponseInitialised.next(true); + }, + error: () => { + this.healthInfoResponse.next(null); + this.healthInfoResponseInitialised.next(true); + } + }); + + } +} diff --git a/src/app/health-page/health-page.module.ts b/src/app/health-page/health-page.module.ts new file mode 100644 index 0000000000..02a6a91a5f --- /dev/null +++ b/src/app/health-page/health-page.module.ts @@ -0,0 +1,35 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { HealthPageRoutingModule } from './health-page.routing.module'; +import { HealthPanelComponent } from './health-panel/health-panel.component'; +import { HealthStatusComponent } from './health-panel/health-status/health-status.component'; +import { SharedModule } from '../shared/shared.module'; +import { HealthPageComponent } from './health-page.component'; +import { HealthComponentComponent } from './health-panel/health-component/health-component.component'; +import { HealthInfoComponent } from './health-info/health-info.component'; +import { HealthInfoComponentComponent } from './health-info/health-info-component/health-info-component.component'; + + +@NgModule({ + imports: [ + CommonModule, + HealthPageRoutingModule, + NgbModule, + SharedModule, + TranslateModule + ], + declarations: [ + HealthPageComponent, + HealthPanelComponent, + HealthStatusComponent, + HealthComponentComponent, + HealthInfoComponent, + HealthInfoComponentComponent, + ] +}) +export class HealthPageModule { +} diff --git a/src/app/health-page/health-page.routing.module.ts b/src/app/health-page/health-page.routing.module.ts new file mode 100644 index 0000000000..82d541dc31 --- /dev/null +++ b/src/app/health-page/health-page.routing.module.ts @@ -0,0 +1,28 @@ +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; + +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { HealthPageComponent } from './health-page.component'; +import { + SiteAdministratorGuard +} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { + breadcrumbKey: 'health', + title: 'health-page.title', + }, + canActivate: [SiteAdministratorGuard], + component: HealthPageComponent + } + ]) + ] +}) +export class HealthPageRoutingModule { + +} diff --git a/src/app/health-page/health-panel/health-component/health-component.component.html b/src/app/health-page/health-panel/health-component/health-component.component.html new file mode 100644 index 0000000000..1f29c8c9fc --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.html @@ -0,0 +1,30 @@ + +
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+ +
+

{{ getPropertyLabel(item.key) | titlecase }} : {{item.value}}

+
+
+ + + diff --git a/src/app/health-page/health-panel/health-component/health-component.component.scss b/src/app/health-page/health-panel/health-component/health-component.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-panel/health-component/health-component.component.spec.ts b/src/app/health-page/health-panel/health-component/health-component.component.spec.ts new file mode 100644 index 0000000000..a8ec2b65e0 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthComponentComponent } from './health-component.component'; +import { HealthComponentOne, HealthComponentTwo } from '../../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('HealthComponentComponent', () => { + let component: HealthComponentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbCollapseModule, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthComponentComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthComponentComponent); + component = fixture.componentInstance; + }); + + describe('when has nested components', () => { + beforeEach(() => { + component.healthComponentName = 'db'; + component.healthComponent = HealthComponentOne; + component.isCollapsed = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create collapsible divs properly', () => { + const collapseDivs = fixture.debugElement.queryAll(By.css('[data-test="collapse"]')); + expect(collapseDivs.length).toBe(2); + const detailsDivs = fixture.debugElement.queryAll(By.css('[data-test="details"]')); + expect(detailsDivs.length).toBe(6); + }); + }); + + describe('when has details', () => { + beforeEach(() => { + component.healthComponentName = 'geoIp'; + component.healthComponent = HealthComponentTwo; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create detail divs properly', () => { + const detailsDivs = fixture.debugElement.queryAll(By.css('[data-test="details"]')); + expect(detailsDivs.length).toBe(1); + const collapseDivs = fixture.debugElement.queryAll(By.css('[data-test="collapse"]')); + expect(collapseDivs.length).toBe(0); + }); + }); +}); diff --git a/src/app/health-page/health-panel/health-component/health-component.component.ts b/src/app/health-page/health-panel/health-component/health-component.component.ts new file mode 100644 index 0000000000..e212a07289 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.ts @@ -0,0 +1,53 @@ +import { Component, Input } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthComponent } from '../../models/health-component.model'; +import { AlertType } from '../../../shared/alert/aletr-type'; + +/** + * A component to render a "health component" object. + * + * Note that the word "component" in "health component" doesn't refer to Angular use of the term + * but rather to the components used in the response of the health endpoint of Spring's Actuator + * API. + */ +@Component({ + selector: 'ds-health-component', + templateUrl: './health-component.component.html', + styleUrls: ['./health-component.component.scss'] +}) +export class HealthComponentComponent { + + /** + * The HealthComponent object to display + */ + @Input() healthComponent: HealthComponent; + + /** + * The HealthComponent object name + */ + @Input() healthComponentName: string; + + public AlertTypeEnum = AlertType; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = false; + + constructor(private translate: TranslateService) { + } + + /** + * Return translated label if exist for the given property + * + * @param property + */ + public getPropertyLabel(property: string): string { + const translationKey = `health-page.property.${property}`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? property : translation; + } +} diff --git a/src/app/health-page/health-panel/health-panel.component.html b/src/app/health-page/health-panel/health-panel.component.html new file mode 100644 index 0000000000..2d67fa537b --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.html @@ -0,0 +1,25 @@ +

{{'health-page.status' | translate}} :

+ + + +
+ +
+ +
+ + +
+
+
+
+ + + +
+
+ + diff --git a/src/app/health-page/health-panel/health-panel.component.scss b/src/app/health-page/health-panel/health-panel.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-panel/health-panel.component.spec.ts b/src/app/health-page/health-panel/health-panel.component.spec.ts new file mode 100644 index 0000000000..1d9c856ddb --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.spec.ts @@ -0,0 +1,57 @@ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { HealthPanelComponent } from './health-panel.component'; +import { HealthResponseObj } from '../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../shared/utils/object-ngfor.pipe'; + +describe('HealthPanelComponent', () => { + let component: HealthPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbNavModule, + NgbAccordionModule, + CommonModule, + BrowserAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [ + HealthPanelComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthPanelComponent); + component = fixture.componentInstance; + component.healthResponse = HealthResponseObj; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render a panel for each component', () => { + const components = fixture.debugElement.queryAll(By.css('[data-test="component"]')); + expect(components.length).toBe(5); + }); + +}); diff --git a/src/app/health-page/health-panel/health-panel.component.ts b/src/app/health-page/health-panel/health-panel.component.ts new file mode 100644 index 0000000000..1c056daf20 --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthResponse } from '../models/health-component.model'; + +/** + * Show the health panel + */ +@Component({ + selector: 'ds-health-panel', + templateUrl: './health-panel.component.html', + styleUrls: ['./health-panel.component.scss'] +}) +export class HealthPanelComponent implements OnInit { + + /** + * Health endpoint response + */ + @Input() healthResponse: HealthResponse; + + /** + * The first active panel id + */ + activeId: string; + + constructor(private translate: TranslateService) { + } + + ngOnInit(): void { + this.activeId = Object.keys(this.healthResponse.components)[0]; + } + + /** + * Return translated label if exist for the given property + * + * @param panelKey + */ + public getPanelLabel(panelKey: string): string { + const translationKey = `health-page.section.${panelKey}.title`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? panelKey : translation; + } +} diff --git a/src/app/health-page/health-panel/health-status/health-status.component.html b/src/app/health-page/health-panel/health-status/health-status.component.html new file mode 100644 index 0000000000..38a6f72601 --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.html @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.scss b/src/app/health-page/health-panel/health-status/health-status.component.scss similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.scss rename to src/app/health-page/health-panel/health-status/health-status.component.scss diff --git a/src/app/health-page/health-panel/health-status/health-status.component.spec.ts b/src/app/health-page/health-panel/health-status/health-status.component.spec.ts new file mode 100644 index 0000000000..f0f61ebdbb --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { HealthStatusComponent } from './health-status.component'; +import { HealthStatus } from '../../models/health-component.model'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +describe('HealthStatusComponent', () => { + let component: HealthStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbTooltipModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ HealthStatusComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthStatusComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create success icon', () => { + component.status = HealthStatus.UP; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-success')); + expect(icon).toBeTruthy(); + }); + + it('should create warning icon', () => { + component.status = HealthStatus.UP_WITH_ISSUES; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-warning')); + expect(icon).toBeTruthy(); + }); + + it('should create success icon', () => { + component.status = HealthStatus.DOWN; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-danger')); + expect(icon).toBeTruthy(); + }); +}); diff --git a/src/app/health-page/health-panel/health-status/health-status.component.ts b/src/app/health-page/health-panel/health-status/health-status.component.ts new file mode 100644 index 0000000000..19f83713fc --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { HealthStatus } from '../../models/health-component.model'; + +/** + * Show a health status object + */ +@Component({ + selector: 'ds-health-status', + templateUrl: './health-status.component.html', + styleUrls: ['./health-status.component.scss'] +}) +export class HealthStatusComponent { + /** + * The current status to show + */ + @Input() status: HealthStatus; + + /** + * He + */ + HealthStatus = HealthStatus; + +} diff --git a/src/app/health-page/health.service.ts b/src/app/health-page/health.service.ts new file mode 100644 index 0000000000..7c238769a1 --- /dev/null +++ b/src/app/health-page/health.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { DspaceRestService } from '../core/dspace-rest/dspace-rest.service'; +import { RawRestResponse } from '../core/dspace-rest/raw-rest-response.model'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; + +@Injectable({ + providedIn: 'root' +}) +export class HealthService { + constructor(protected halService: HALEndpointService, + protected restService: DspaceRestService) { + } + /** + * @returns health data + */ + getHealth(): Observable { + return this.halService.getEndpoint('/actuator').pipe( + map((restURL: string) => restURL + '/health'), + switchMap((endpoint: string) => this.restService.get(endpoint))); + } + + /** + * @returns information of server + */ + getInfo(): Observable { + return this.halService.getEndpoint('/actuator').pipe( + map((restURL: string) => restURL + '/info'), + switchMap((endpoint: string) => this.restService.get(endpoint))); + } +} diff --git a/src/app/health-page/models/health-component.model.ts b/src/app/health-page/models/health-component.model.ts new file mode 100644 index 0000000000..8461d4d967 --- /dev/null +++ b/src/app/health-page/models/health-component.model.ts @@ -0,0 +1,48 @@ +/** + * Interface for Health Status + */ +export enum HealthStatus { + UP = 'UP', + UP_WITH_ISSUES = 'UP_WITH_ISSUES', + DOWN = 'DOWN' +} + +/** + * Interface describing the Health endpoint response + */ +export interface HealthResponse { + status: HealthStatus; + components: { + [name: string]: HealthComponent; + }; +} + +/** + * Interface describing a single component retrieved from the Health endpoint response + */ +export interface HealthComponent { + status: HealthStatus; + details?: { + [name: string]: number|string; + }; + components?: { + [name: string]: HealthComponent; + }; +} + +/** + * Interface describing the Health info endpoint response + */ +export interface HealthInfoResponse { + [name: string]: HealthInfoComponent|string; +} + +/** + * Interface describing a single component retrieved from the Health info endpoint response + */ +export interface HealthInfoComponent { + [property: string]: HealthInfoComponent|string; +} + + + diff --git a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts index eb52ca9243..2561770942 100644 --- a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts +++ b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts @@ -25,6 +25,13 @@ 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 { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +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('TopLevelCommunityList Component', () => { let comp: TopLevelCommunityListComponent; @@ -114,6 +121,25 @@ describe('TopLevelCommunityList 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: [ @@ -130,6 +156,10 @@ describe('TopLevelCommunityList 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/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index 97901bd7c8..cf4a3de74b 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '../../shared/shared.module'; import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; @@ -48,7 +48,8 @@ import { ResourcePoliciesModule } from '../../shared/resource-policies/resource- EditItemPageRoutingModule, SearchPageModule, DragDropModule, - ResourcePoliciesModule + ResourcePoliciesModule, + NgbModule ], declarations: [ EditItemPageComponent, diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html index 71aa7b44de..5437525185 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html @@ -1,13 +1,33 @@
- - - - - + + + + + + +
+
+ +
+
+ + + +
+ +
+
+
+
+ +
- diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss new file mode 100644 index 0000000000..c3694e6784 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss @@ -0,0 +1,4 @@ +.auth-bitstream-container { + margin-top: -1em; + margin-bottom: 1.5em; +} diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts index 97280c3ea0..2fe8a562c6 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -1,11 +1,12 @@ +import { Observable } from 'rxjs/internal/Observable'; import { waitForAsync, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; -import { ItemAuthorizationsComponent } from './item-authorizations.component'; +import { ItemAuthorizationsComponent, BitstreamMapValue } from './item-authorizations.component'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bundle } from '../../../core/shared/bundle.model'; import { Item } from '../../../core/shared/item.model'; @@ -57,8 +58,6 @@ describe('ItemAuthorizationsComponent test suite', () => { bitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream3, bitstream4])) }); const bundles = [bundle1, bundle2]; - const bitstreamList1: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream1, bitstream2]); - const bitstreamList2: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream3, bitstream4]); const item = Object.assign(new Item(), { uuid: 'item', @@ -142,13 +141,12 @@ describe('ItemAuthorizationsComponent test suite', () => { expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy(); expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy(); let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList1 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a : [bitstream1, bitstream2] })); - bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList2 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a: [bitstream3, bitstream4] })); }); diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts index 76597a135b..8ed2f9a12e 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -6,7 +8,8 @@ import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { - getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload, + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload, } from '../../../core/shared/operators'; import { Item } from '../../../core/shared/item.model'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -25,7 +28,8 @@ interface BundleBitstreamsMapEntry { @Component({ selector: 'ds-item-authorizations', - templateUrl: './item-authorizations.component.html' + templateUrl: './item-authorizations.component.html', + styleUrls:['./item-authorizations.component.scss'] }) /** * Component that handles the item Authorizations @@ -36,13 +40,13 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * A map that contains all bitstream of the item's bundles * @type {Observable>>>} */ - public bundleBitstreamsMap: Map>> = new Map>>(); + public bundleBitstreamsMap: Map = new Map(); /** - * The list of bundle for the item + * The list of all bundles for the item * @type {Observable>} */ - private bundles$: BehaviorSubject = new BehaviorSubject([]); + bundles$: BehaviorSubject = new BehaviorSubject([]); /** * The target editing item @@ -56,15 +60,48 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { */ private subs: Subscription[] = []; + /** + * The size of the bundles to be loaded on demand + * @type {number} + */ + bundlesPerPage = 6; + + /** + * The number of current page + * @type {number} + */ + bundlesPageSize = 1; + + /** + * The flag to show or not the 'Load more' button + * based on the condition if all the bundles are loaded or not + * @type {boolean} + */ + allBundlesLoaded = false; + + /** + * Initial size of loaded bitstreams + * The size of incrementation used in bitstream pagination + */ + bitstreamSize = 4; + + /** + * The size of the loaded bitstremas at a certain moment + * @private + */ + private bitstreamPageSize = 4; + /** * Initialize instance variables * * @param {LinkService} linkService * @param {ActivatedRoute} route + * @param nameService */ constructor( private linkService: LinkService, - private route: ActivatedRoute + private route: ActivatedRoute, + private nameService: DSONameService ) { } @@ -72,16 +109,53 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * Initialize the component, setting up the bundle and bitstream within the item */ ngOnInit(): void { + this.getBundlesPerItem(); + } + + /** + * Return the item's UUID + */ + getItemUUID(): Observable { + return this.item$.pipe( + map((item: Item) => item.id), + first((UUID: string) => isNotEmpty(UUID)) + ); + } + + /** + * Return the item's name + */ + getItemName(): Observable { + return this.item$.pipe( + map((item: Item) => this.nameService.getName(item)) + ); + } + + /** + * Return all item's bundles + * + * @return an observable that emits all item's bundles + */ + getItemBundles(): Observable { + return this.bundles$.asObservable(); + } + + /** + * Get all bundles per item + * and all the bitstreams per bundle + * @param page number of current page + */ + getBundlesPerItem(page: number = 1) { this.item$ = this.route.data.pipe( map((data) => data.dso), getFirstSucceededRemoteDataWithNotEmptyPayload(), map((item: Item) => this.linkService.resolveLink( item, - followLink('bundles', {}, followLink('bitstreams')) + followLink('bundles', {findListOptions: {currentPage : page, elementsPerPage: this.bundlesPerPage}}, followLink('bitstreams')) )) ) as Observable; - const bundles$: Observable> = this.item$.pipe( + const bundles$: Observable> = this.item$.pipe( filter((item: Item) => isNotEmpty(item.bundles)), mergeMap((item: Item) => item.bundles), getFirstSucceededRemoteDataWithNotEmptyPayload(), @@ -96,37 +170,36 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { take(1), map((list: PaginatedList) => list.page) ).subscribe((bundles: Bundle[]) => { - this.bundles$.next(bundles); + if (isEqual(bundles.length,0) || bundles.length < this.bundlesPerPage) { + this.allBundlesLoaded = true; + } + if (isEqual(page, 1)) { + this.bundles$.next(bundles); + } else { + this.bundles$.next(this.bundles$.getValue().concat(bundles)); + } }), bundles$.pipe( take(1), mergeMap((list: PaginatedList) => list.page), map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) })) ).subscribe((entry: BundleBitstreamsMapEntry) => { - this.bundleBitstreamsMap.set(entry.id, entry.bitstreams); + let bitstreamMapValues: BitstreamMapValue = { + isCollapsed: true, + allBitstreamsLoaded: false, + bitstreams: null + }; + bitstreamMapValues.bitstreams = entry.bitstreams.pipe( + map((b: PaginatedList) => { + bitstreamMapValues.allBitstreamsLoaded = b?.page.length < this.bitstreamSize; + return [...b.page.slice(0, this.bitstreamSize)]; + }) + ); + this.bundleBitstreamsMap.set(entry.id, bitstreamMapValues); }) ); } - /** - * Return the item's UUID - */ - getItemUUID(): Observable { - return this.item$.pipe( - map((item: Item) => item.id), - first((UUID: string) => isNotEmpty(UUID)) - ); - } - - /** - * Return all item's bundles - * - * @return an observable that emits all item's bundles - */ - getItemBundles(): Observable { - return this.bundles$.asObservable(); - } - /** * Return all bundle's bitstreams * @@ -142,6 +215,46 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { ); } + /** + * Changes the collapsible state of the area that contains the bitstream list + * @param bundleId Id of bundle responsible for the requested bitstreams + */ + collapseArea(bundleId: string) { + this.bundleBitstreamsMap.get(bundleId).isCollapsed = !this.bundleBitstreamsMap.get(bundleId).isCollapsed; + } + + /** + * Loads as much bundles as initial value of bundleSize to be displayed + */ + onBundleLoad(){ + this.bundlesPageSize ++; + this.getBundlesPerItem(this.bundlesPageSize); + } + + /** + * Calculates the bitstreams that are going to be loaded on demand, + * based on the number configured on this.bitstreamSize. + * @param bundle parent of bitstreams that are requested to be shown + * @returns Subscription + */ + onBitstreamsLoad(bundle: Bundle) { + return this.getBundleBitstreams(bundle).subscribe((res: PaginatedList) => { + let nextBitstreams = res?.page.slice(this.bitstreamPageSize, this.bitstreamPageSize + this.bitstreamSize); + let bitstreamsToShow = this.bundleBitstreamsMap.get(bundle.id).bitstreams.pipe( + map((existingBits: Bitstream[])=> { + return [... existingBits, ...nextBitstreams]; + }) + ); + this.bitstreamPageSize = this.bitstreamPageSize + this.bitstreamSize; + let bitstreamMapValues: BitstreamMapValue = { + bitstreams: bitstreamsToShow , + isCollapsed: this.bundleBitstreamsMap.get(bundle.id).isCollapsed, + allBitstreamsLoaded: res?.page.length <= this.bitstreamPageSize + }; + this.bundleBitstreamsMap.set(bundle.id, bitstreamMapValues); + }); + } + /** * Unsubscribe from all subscriptions */ @@ -151,3 +264,9 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } } + +export interface BitstreamMapValue { + bitstreams: Observable; + isCollapsed: boolean; + allBitstreamsLoaded: boolean; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 46d172dadc..7de575b785 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -50,3 +50,7 @@ cursor: grabbing; } } + +:host ::ng-deep .larger-tooltip .tooltip-inner { + max-width: 500px; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 7599f95ed6..0c7dfb1e34 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -147,7 +147,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme // Perform the setup actions from above in order and display notifications removedResponses$.pipe(take(1)).subscribe((responses: RemoteData[]) => { this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); - this.reset(); this.submitting = false; }); } @@ -242,27 +241,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme ); } - /** - * De-cache the current item (it should automatically reload due to itemUpdateSubscription) - */ - reset() { - this.refreshItemCache(); - } - - /** - * Remove the current item's cache from object- and request-cache - */ - refreshItemCache() { - this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => { - bundles.forEach((bundle: Bundle) => { - this.objectCache.remove(bundle.self); - this.requestService.removeByHrefSubstring(bundle.self); - }); - this.objectCache.remove(this.item.self); - this.requestService.removeByHrefSubstring(this.item.self); - }); - } - /** * Unsubscribe from open subscriptions whenever the component gets destroyed */ diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html index 24db0ec300..0f0fad2199 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -9,9 +9,10 @@
- - {{ bitstream?.firstMetadataValue('dc.description') }} - +
+ {{ bitstream?.firstMetadataValue('dc.description') }} +
@@ -27,7 +28,7 @@ + [attr.data-test]="'download-button' | dsBrowserOnly">
-

{{'researcher.profile.not.associated' | translate}}

- diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts new file mode 100644 index 0000000000..168517b47a --- /dev/null +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts @@ -0,0 +1,186 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { PersonPageClaimButtonComponent } from './person-page-claim-button.component'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../testing/route-service.stub'; +import { Item } from '../../../core/shared/item.model'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +describe('PersonPageClaimButtonComponent', () => { + let scheduler: TestScheduler; + let component: PersonPageClaimButtonComponent; + let fixture: ComponentFixture; + + const mockItem: Item = Object.assign(new Item(), { + metadata: { + 'person.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.birthDate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'person.familyName': [ + { + language: 'en_US', + value: 'Doe' + } + ], + 'person.givenName': [ + { + language: 'en_US', + value: 'John' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + + const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: 'test-id', + visible: true, + type: 'profile', + _links: { + item: { + href: 'https://rest.api/rest/api/profiles/test-id/item' + }, + self: { + href: 'https://rest.api/rest/api/profiles/test-id' + }, + } + }); + + const notificationsService = new NotificationsServiceStub(); + + const authorizationDataService = jasmine.createSpyObj('authorizationDataService', { + isAuthorized: jasmine.createSpy('isAuthorized') + }); + + const researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + createFromExternalSource: jasmine.createSpy('createFromExternalSource'), + findRelatedItemId: jasmine.createSpy('findRelatedItemId'), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [PersonPageClaimButtonComponent], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: ResearcherProfileService, useValue: researcherProfileService }, + { provide: RouteService, useValue: routeServiceStub }, + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PersonPageClaimButtonComponent); + component = fixture.componentInstance; + component.object = mockItem; + }); + + describe('when item can be claimed', () => { + beforeEach(() => { + authorizationDataService.isAuthorized.and.returnValue(observableOf(true)); + researcherProfileService.createFromExternalSource.calls.reset(); + researcherProfileService.findRelatedItemId.calls.reset(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create claim button', () => { + const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); + expect(btn).toBeTruthy(); + }); + + describe('claim', () => { + describe('when successfully', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.createFromExternalSource.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + researcherProfileService.findRelatedItemId.and.returnValue(observableOf('test-id')); + }); + + it('should display success notification', () => { + scheduler.schedule(() => component.claim()); + scheduler.flush(); + + expect(researcherProfileService.findRelatedItemId).toHaveBeenCalled(); + expect(notificationsService.success).toHaveBeenCalled(); + }); + }); + + describe('when not successfully', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.createFromExternalSource.and.returnValue(createFailedRemoteDataObject$()); + }); + + it('should display success notification', () => { + scheduler.schedule(() => component.claim()); + scheduler.flush(); + + expect(researcherProfileService.findRelatedItemId).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + + }); + + describe('when item cannot be claimed', () => { + beforeEach(() => { + authorizationDataService.isAuthorized.and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create claim button', () => { + const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); + expect(btn).toBeFalsy(); + }); + + }); +}); diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts new file mode 100644 index 0000000000..903b9d3679 --- /dev/null +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts @@ -0,0 +1,84 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { mergeMap, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { RouteService } from '../../../core/services/route.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; +import { isNotEmpty } from '../../empty.util'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-person-page-claim-button', + templateUrl: './person-page-claim-button.component.html', + styleUrls: ['./person-page-claim-button.component.scss'] +}) +export class PersonPageClaimButtonComponent implements OnInit { + + /** + * The target person item to claim + */ + @Input() object: DSpaceObject; + + /** + * A boolean representing if item can be claimed or not + */ + claimable$: BehaviorSubject = new BehaviorSubject(false); + + constructor(protected routeService: RouteService, + protected authorizationService: AuthorizationDataService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected researcherProfileService: ResearcherProfileService) { + } + + ngOnInit(): void { + this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.object._links.self.href, null, false).pipe( + take(1) + ).subscribe((isAuthorized: boolean) => { + this.claimable$.next(isAuthorized); + }); + + } + + /** + * Create a new researcher profile claiming the current item. + */ + claim() { + this.researcherProfileService.createFromExternalSource(this.object._links.self.href).pipe( + getFirstCompletedRemoteData(), + mergeMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return this.researcherProfileService.findRelatedItemId(rd.payload); + } else { + return observableOf(null); + } + })) + .subscribe((id: string) => { + if (isNotEmpty(id)) { + this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), + this.translate.get('researcherprofile.success.claim.body')); + this.claimable$.next(false); + } else { + this.notificationsService.error( + this.translate.get('researcherprofile.error.claim.title'), + this.translate.get('researcherprofile.error.claim.body')); + } + }); + } + + /** + * Returns true if the item is claimable, false otherwise. + */ + isClaimable(): Observable { + return this.claimable$; + } + +} diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index 6438c0eac4..0c799369ef 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -143,7 +143,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { this.typesString = this.types.map((type: string) => type.toString().toLowerCase()).join(', '); // Create an observable searching for the current DSO (return empty list if there's no current DSO) - let currentDSOResult$; + let currentDSOResult$: Observable>>; if (isNotEmpty(this.currentDSOId)) { currentDSOResult$ = this.search(this.getCurrentDSOQuery(), 1).pipe(getFirstSucceededRemoteDataPayload()); } else { diff --git a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts deleted file mode 100644 index 464f2518ca..0000000000 --- a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ActivatedRoute, Router } from '@angular/router'; -/* tslint:disable:no-unused-variable */ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ClaimItemSelectorComponent } from './claim-item-selector.component'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ProfileClaimService } from '../../../../profile-page/profile-claim/profile-claim.service'; -import { of } from 'rxjs'; - -describe('ClaimItemSelectorComponent', () => { - let component: ClaimItemSelectorComponent; - let fixture: ComponentFixture; - - const profileClaimService = jasmine.createSpyObj('profileClaimService', { - search: of({ payload: {page: []}}) - }); - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [ ClaimItemSelectorComponent ], - providers: [ - { provide: NgbActiveModal, useValue: {} }, - { provide: ActivatedRoute, useValue: {} }, - { provide: Router, useValue: {} }, - { provide: ProfileClaimService, useValue: profileClaimService } - ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ClaimItemSelectorComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - -}); diff --git a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts deleted file mode 100644 index 5c5ee42037..0000000000 --- a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { BehaviorSubject } from 'rxjs'; -import { PaginatedList } from '../../../../core/data/paginated-list.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Item } from '../../../../core/shared/item.model'; -import { SearchResult } from '../../../search/models/search-result.model'; -import { DSOSelectorModalWrapperComponent } from '../dso-selector-modal-wrapper.component'; -import { getItemPageRoute } from '../../../../item-page/item-page-routing-paths'; -import { EPerson } from '../../../../core/eperson/models/eperson.model'; -import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { ProfileClaimService } from '../../../../profile-page/profile-claim/profile-claim.service'; -import { CollectionElementLinkType } from '../../../object-collection/collection-element-link.type'; - - - -@Component({ - selector: 'ds-claim-item-selector', - templateUrl: './claim-item-selector.component.html' -}) -export class ClaimItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { - - @Input() dso: DSpaceObject; - - listEntries$: BehaviorSubject>>> = new BehaviorSubject(null); - - viewMode = ViewMode.ListElement; - - // enum to be exposed - linkTypes = CollectionElementLinkType; - - checked = false; - - @Output() create: EventEmitter = new EventEmitter(); - - constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router, - private profileClaimService: ProfileClaimService) { - super(activeModal, route); - } - - ngOnInit(): void { - this.profileClaimService.search(this.dso as EPerson).subscribe( - (result) => this.listEntries$.next(result) - ); - } - - // triggered when an item is selected - selectItem(dso: DSpaceObject): void { - this.close(); - this.navigate(dso); - } - - navigate(dso: DSpaceObject) { - this.router.navigate([getItemPageRoute(dso as Item)]); - } - - toggleCheckbox() { - this.checked = !this.checked; - } - - createFromScratch() { - this.create.emit(); - this.close(); - } - -} diff --git a/src/app/shared/empty.util.spec.ts b/src/app/shared/empty.util.spec.ts index 1112883c2a..94122990ae 100644 --- a/src/app/shared/empty.util.spec.ts +++ b/src/app/shared/empty.util.spec.ts @@ -9,7 +9,7 @@ import { isNotEmptyOperator, isNotNull, isNotUndefined, - isNull, + isNull, isObjectEmpty, isUndefined } from './empty.util'; @@ -444,6 +444,43 @@ describe('Empty Utils', () => { }); }); + describe('isObjectEmpty', () => { + /* + isObjectEmpty(); // true + isObjectEmpty(null); // true + isObjectEmpty(undefined); // true + isObjectEmpty(''); // true + isObjectEmpty([]); // true + isObjectEmpty({}); // true + isObjectEmpty({name: null}); // true + isObjectEmpty({ name: 'Adam Hawkins', surname : null}); // false + */ + it('should be empty if no parameter passed', () => { + expect(isObjectEmpty()).toBeTrue(); + }); + it('should be empty if null parameter passed', () => { + expect(isObjectEmpty(null)).toBeTrue(); + }); + it('should be empty if undefined parameter passed', () => { + expect(isObjectEmpty(undefined)).toBeTrue(); + }); + it('should be empty if empty string passed', () => { + expect(isObjectEmpty('')).toBeTrue(); + }); + it('should be empty if empty array passed', () => { + expect(isObjectEmpty([])).toBeTrue(); + }); + it('should be empty if empty object passed', () => { + expect(isObjectEmpty({})).toBeTrue(); + }); + it('should be empty if single key with null value passed', () => { + expect(isObjectEmpty({ name: null })).toBeTrue(); + }); + it('should NOT be empty if object with at least one non-null value passed', () => { + expect(isObjectEmpty({ name: 'Adam Hawkins', surname : null })).toBeFalse(); + }); + }); + describe('ensureArrayHasValue', () => { it('should let all arrays pass unchanged, and turn everything else in to empty arrays', () => { const sourceData = { diff --git a/src/app/shared/empty.util.ts b/src/app/shared/empty.util.ts index d79c520fda..355314550a 100644 --- a/src/app/shared/empty.util.ts +++ b/src/app/shared/empty.util.ts @@ -177,3 +177,29 @@ export const isNotEmptyOperator = () => export const ensureArrayHasValue = () => (source: Observable): Observable => source.pipe(map((arr: T[]): T[] => Array.isArray(arr) ? arr : [])); + +/** + * Verifies that a object keys are all empty or not. + * isObjectEmpty(); // true + * isObjectEmpty(null); // true + * isObjectEmpty(undefined); // true + * isObjectEmpty(''); // true + * isObjectEmpty([]); // true + * isObjectEmpty({}); // true + * isObjectEmpty({name: null}); // true + * isObjectEmpty({ name: 'Adam Hawkins', surname : null}); // false + */ +export function isObjectEmpty(obj?: any): boolean { + + if (typeof(obj) !== 'object') { + return true; + } + + for (const key in obj) { + if (obj.hasOwnProperty(key) && isNotEmpty(obj[key])) { + return false; + } + } + return true; +} + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 55e354ea7a..7eef1d8655 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,4 +1,5 @@
- +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index b67e6f9e46..ffc4df244e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -25,7 +25,7 @@ import { DynamicSliderModel, DynamicSwitchModel, DynamicTextAreaModel, - DynamicTimePickerModel + DynamicTimePickerModel, MATCH_VISIBLE, OR_OPERATOR } from '@ng-dynamic-forms/core'; import { DynamicNGBootstrapCalendarComponent, @@ -65,6 +65,7 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; import { RelationshipService } from '../../../../core/data/relationship.service'; import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -79,6 +80,14 @@ import { SubmissionService } from '../../../../submission/submission.service'; import { FormBuilderService } from '../form-builder.service'; import { NgxMaskModule } from 'ngx-mask'; +function getMockDsDynamicTypeBindRelationService(): DsDynamicTypeBindRelationService { + return jasmine.createSpyObj('DsDynamicTypeBindRelationService', { + getRelatedFormModel: jasmine.createSpy('getRelatedFormModel'), + matchesCondition: jasmine.createSpy('matchesCondition'), + subscribeRelations: jasmine.createSpy('subscribeRelations') + }); +} + describe('DsDynamicFormControlContainerComponent test suite', () => { const vocabularyOptions: VocabularyOptions = { @@ -111,7 +120,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { metadataFields: [], repeatable: false, submissionId: '1234', - hasSelectableMetadata: false + hasSelectableMetadata: false, + typeBindRelations: [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: [{id: 'dc.type', value: 'Book'}] + }] }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', @@ -200,6 +214,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { providers: [ DsDynamicFormControlContainerComponent, DynamicFormService, + { provide: DsDynamicTypeBindRelationService, useValue: getMockDsDynamicTypeBindRelationService() }, { provide: RelationshipService, useValue: {} }, { provide: SelectableListService, useValue: {} }, { provide: ItemDataService, useValue: {} }, @@ -231,7 +246,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { }); })); - beforeEach(inject([DynamicFormService], (service: DynamicFormService) => { + beforeEach(inject([DynamicFormService, FormBuilderService], (service: DynamicFormService, formBuilderService: FormBuilderService) => { formGroup = service.createFormGroup(formModel); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index c3359fd65a..25f650ea7e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -81,6 +81,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/ import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; import { find, map, startWith, switchMap, take } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; import { SearchResult } from '../../../search/models/search-result.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @@ -194,8 +195,10 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; // eslint-disable-next-line @angular-eslint/no-input-rename @Input('templates') inputTemplateList: QueryList; - + @Input() hasMetadataModel: any; @Input() formId: string; + @Input() formGroup: FormGroup; + @Input() formModel: DynamicFormControlModel[]; @Input() asBootstrapFormGroup = false; @Input() bindId = true; @Input() context: any | null = null; @@ -237,6 +240,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo protected dynamicFormComponentService: DynamicFormComponentService, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, + protected typeBindRelationService: DsDynamicTypeBindRelationService, protected translateService: TranslateService, protected relationService: DynamicFormRelationService, private modalService: NgbModal, @@ -343,6 +347,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo if (this.model && this.model.placeholder) { this.model.placeholder = this.translateService.instant(this.model.placeholder); } + if (this.model.typeBindRelations && this.model.typeBindRelations.length > 0) { + this.subscriptions.push(...this.typeBindRelationService.subscribeRelations(this.model, this.control)); + } } } @@ -357,6 +364,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.showErrorMessagesPreviousStage = this.showErrorMessages; } + protected createFormControlComponent(): void { + super.createFormControlComponent(); + if (this.componentType !== null) { + let index; + + if (this.context && this.context instanceof DynamicFormArrayGroupModel) { + index = this.context.index; + } + const instance = this.dynamicFormComponentService.getFormControlRef(this.model, index); + if (instance) { + (instance as any).formModel = this.formModel; + (instance as any).formGroup = this.formGroup; + } + } + } + /** * Since Form Control Components created dynamically have 'OnPush' change detection strategy, * changes are not propagated. So use this method to force an update diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html index 2a18565178..4c1ea2dd96 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.html @@ -3,6 +3,7 @@ [group]="formGroup" [hasErrorMessaging]="model.hasErrorMessages" [hidden]="model.hidden" + [class.d-none]="model.hidden" [layout]="formLayout" [model]="model" [templates]="templates" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts new file mode 100644 index 0000000000..f8bc7ea886 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts @@ -0,0 +1,143 @@ +import {inject, TestBed} from '@angular/core/testing'; + +import { + DynamicFormControlRelation, + DynamicFormRelationService, + MATCH_VISIBLE, + OR_OPERATOR, + HIDDEN_MATCHER, + HIDDEN_MATCHER_PROVIDER, REQUIRED_MATCHER_PROVIDER, DISABLED_MATCHER_PROVIDER, +} from '@ng-dynamic-forms/core'; + +import { + mockInputWithTypeBindModel, MockRelationModel, mockDcTypeInputModel +} from '../../../mocks/form-models.mock'; +import {DsDynamicTypeBindRelationService} from './ds-dynamic-type-bind-relation.service'; +import {FormFieldMetadataValueObject} from '../models/form-field-metadata-value.model'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {FormBuilderService} from '../form-builder.service'; +import {getMockFormBuilderService} from '../../../mocks/form-builder-service.mock'; +import {Injector} from '@angular/core'; + +describe('DSDynamicTypeBindRelationService test suite', () => { + let service: DsDynamicTypeBindRelationService; + let dynamicFormRelationService: DynamicFormRelationService; + let injector: Injector; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + providers: [ + { provide: FormBuilderService, useValue: getMockFormBuilderService() }, + { provide: DsDynamicTypeBindRelationService, useClass: DsDynamicTypeBindRelationService }, + { provide: DynamicFormRelationService }, + DISABLED_MATCHER_PROVIDER, HIDDEN_MATCHER_PROVIDER, REQUIRED_MATCHER_PROVIDER + ] + }).compileComponents().then(); + }); + + beforeEach(inject([DsDynamicTypeBindRelationService, DynamicFormRelationService], + (relationService: DsDynamicTypeBindRelationService, + formRelationService: DynamicFormRelationService, + ) => { + service = relationService; + dynamicFormRelationService = formRelationService; + })); + + describe('Test getTypeBindValue method', () => { + it('Should get type bind "boundType" from the given metadata object value', () => { + const mockMetadataValueObject: FormFieldMetadataValueObject = new FormFieldMetadataValueObject( + 'boundType', null, null, 'Bound Type' + ); + const bindType = service.getTypeBindValue(mockMetadataValueObject); + expect(bindType).toBe('boundType'); + }); + it('Should get type authority key "bound-auth-key" from the given metadata object value', () => { + const mockMetadataValueObject: FormFieldMetadataValueObject = new FormFieldMetadataValueObject( + 'boundType', null, 'bound-auth-key', 'Bound Type' + ); + const bindType = service.getTypeBindValue(mockMetadataValueObject); + expect(bindType).toBe('bound-auth-key'); + }); + it('Should get passed string returned directly as string passed instead of metadata', () => { + const bindType = service.getTypeBindValue('rawString'); + expect(bindType).toBe('rawString'); + }); + it('Should get "undefined" returned directly as no object given', () => { + const bindType = service.getTypeBindValue(undefined); + expect(bindType).toBeUndefined(); + }); + }); + + describe('Test getRelatedFormModel method', () => { + it('Should get 0 related form models for simple type bind mock data', () => { + const testModel = MockRelationModel; + const relatedModels = service.getRelatedFormModel(testModel); + expect(relatedModels).toHaveSize(0); + }); + it('Should get 1 related form models for mock relation model data', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const relatedModels = service.getRelatedFormModel(testModel); + expect(relatedModels).toHaveSize(1); + }); + }); + + describe('Test matchesCondition method', () => { + it('Should receive one subscription to dc.type type binding"', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + let subscriptions = service.subscribeRelations(testModel, dcTypeControl); + expect(subscriptions).toHaveSize(1); + }); + + it('Expect hasMatch to be true (ie. this should be hidden)', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + testModel.typeBindRelations[0].when[0].value = 'anotherType'; + const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER); + const matcher = HIDDEN_MATCHER; + if (relation !== undefined) { + const hasMatch = service.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, testModel, dcTypeControl, injector); + expect(hasMatch).toBeTruthy(); + } + }); + + it('Expect hasMatch to be false (ie. this should NOT be hidden)', () => { + const testModel = mockInputWithTypeBindModel; + testModel.typeBindRelations = getTypeBindRelations(['boundType']); + const dcTypeControl = new FormControl(); + dcTypeControl.setValue('boundType'); + testModel.typeBindRelations[0].when[0].value = 'boundType'; + const relation = dynamicFormRelationService.findRelationByMatcher((testModel as any).typeBindRelations, HIDDEN_MATCHER); + const matcher = HIDDEN_MATCHER; + if (relation !== undefined) { + const hasMatch = service.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, testModel, dcTypeControl, injector); + expect(hasMatch).toBeFalsy(); + } + }); + + }); + +}); + +function getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: 'dc.type', + value: value + }); + }); + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts new file mode 100644 index 0000000000..5dd4a6627d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -0,0 +1,230 @@ +import { Inject, Injectable, Injector, Optional } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { Subscription } from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +import { + AND_OPERATOR, + DYNAMIC_MATCHERS, + DynamicFormControlCondition, + DynamicFormControlMatcher, + DynamicFormControlModel, + DynamicFormControlRelation, + DynamicFormRelationService, MATCH_VISIBLE, + OR_OPERATOR +} from '@ng-dynamic-forms/core'; + +import {hasNoValue, hasValue} from '../../../empty.util'; +import { FormBuilderService } from '../form-builder.service'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-constants'; + +/** + * Service to manage type binding for submission input fields + * Any form component with the typeBindRelations DynamicFormControlRelation property can be controlled this way + */ +@Injectable() +export class DsDynamicTypeBindRelationService { + + constructor(@Optional() @Inject(DYNAMIC_MATCHERS) private dynamicMatchers: DynamicFormControlMatcher[], + protected dynamicFormRelationService: DynamicFormRelationService, + protected formBuilderService: FormBuilderService, + protected injector: Injector) { + } + + /** + * Return the string value of the type bind model + * @param bindModelValue + * @private + */ + public getTypeBindValue(bindModelValue: string | FormFieldMetadataValueObject): string { + let value; + if (hasNoValue(bindModelValue) || typeof bindModelValue === 'string') { + value = bindModelValue; + } else if (bindModelValue instanceof FormFieldMetadataValueObject + && bindModelValue.hasAuthority()) { + value = bindModelValue.authority; + } else { + value = bindModelValue.value; + } + + return value; + } + + + /** + * Get models for this bind type + * @param model + */ + public getRelatedFormModel(model: DynamicFormControlModel): DynamicFormControlModel[] { + + const models: DynamicFormControlModel[] = []; + + (model as any).typeBindRelations.forEach((relGroup) => relGroup.when.forEach((rel) => { + + if (model.id === rel.id) { + throw new Error(`FormControl ${model.id} cannot depend on itself`); + } + + const bindModel: DynamicFormControlModel = this.formBuilderService.getTypeBindModel(); + + if (model && !models.some((modelElement) => modelElement === bindModel)) { + models.push(bindModel); + } + })); + + return models; + } + + /** + * Return false if the type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) matches the value in + * matcher.match or true if the opposite match. Since this is called with regard to actively *hiding* a form + * component, the negation of the comparison is returned. + * @param relation type bind relation (eg. {MATCH_VISIBLE, OR, ['book', 'book part']}) + * @param matcher contains 'match' value and an onChange() event listener + */ + public matchesCondition(relation: DynamicFormControlRelation, matcher: DynamicFormControlMatcher): boolean { + + // Default to OR for operator (OR is explicitly set in field-parser.ts anyway) + const operator = relation.operator || OR_OPERATOR; + + + return relation.when.reduce((hasAlreadyMatched: boolean, condition: DynamicFormControlCondition, index: number) => { + // Get the DynamicFormControlModel (typeBindModel) from the form builder service, set in the form builder + // in the form model at init time in formBuilderService.modelFromConfiguration (called by other form components + // like relation group component and submission section form component). + // This model (DynamicRelationGroupModel) contains eg. mandatory field, formConfiguration, relationFields, + // submission scope, form/section type and other high level properties + const bindModel: any = this.formBuilderService.getTypeBindModel(); + + let values: string[]; + let bindModelValue = bindModel.value; + + // If the form type is RELATION, set bindModelValue to the mandatory field for this model, otherwise leave + // as plain value + if (bindModel.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP) { + bindModelValue = bindModel.value.map((entry) => entry[bindModel.mandatoryField]); + } + // Support multiple bind models + if (Array.isArray(bindModelValue)) { + values = [...bindModelValue.map((entry) => this.getTypeBindValue(entry))]; + } else { + values = [this.getTypeBindValue(bindModelValue)]; + } + + // If bind model evaluates to 'true' (is not undefined, is not null, is not false etc, + // AND the relation match (type bind) is equal to the matcher match (item publication type), then the return + // value is initialised as false. + let returnValue = (!(bindModel && relation.match === matcher.match)); + + // Iterate the type bind values parsed and mapped from our form/relation group model + for (const value of values) { + if (bindModel && relation.match === matcher.match) { + // If we're not at the first array element, and we're using the AND operator, and we have not + // yet matched anything, return false. + if (index > 0 && operator === AND_OPERATOR && !hasAlreadyMatched) { + return false; + } + // If we're not at the first array element, and we're using the OR operator (almost always the case) + // and we've already matched then there is no need to continue, just return true. + if (index > 0 && operator === OR_OPERATOR && hasAlreadyMatched) { + return true; + } + + // Do the actual match. Does condition.value (the item publication type) match the field model + // type bind currently being inspected? + returnValue = condition.value === value; + + // If return value is already true, break. + if (returnValue) { + break; + } + } + + // Test opposingMatch (eg. if match is VISIBLE, opposingMatch will be HIDDEN) + if (bindModel && relation.match === matcher.opposingMatch) { + // If we're not at the first element, using AND, and already matched, just return true here + if (index > 0 && operator === AND_OPERATOR && hasAlreadyMatched) { + return true; + } + + // If we're not at the first element, using OR, and we have NOT already matched, return false + if (index > 0 && operator === OR_OPERATOR && !hasAlreadyMatched) { + return false; + } + + // Negated comparison for return value since this is expected to be in the context of a HIDDEN_MATCHER + returnValue = !(condition.value === value); + + // Break if already false + if (!returnValue) { + break; + } + } + } + return returnValue; + }, false); + } + + /** + * Return an array of subscriptions to a calling component + * @param model + * @param control + */ + subscribeRelations(model: DynamicFormControlModel, control: FormControl): Subscription[] { + + const relatedModels = this.getRelatedFormModel(model); + const subscriptions: Subscription[] = []; + + Object.values(relatedModels).forEach((relatedModel: any) => { + + if (hasValue(relatedModel)) { + const initValue = (hasNoValue(relatedModel.value) || typeof relatedModel.value === 'string') ? relatedModel.value : + (Array.isArray(relatedModel.value) ? relatedModel.value : relatedModel.value.value); + + const valueChanges = relatedModel.valueChanges.pipe( + startWith(initValue) + ); + + // Build up the subscriptions to watch for changes; + subscriptions.push(valueChanges.subscribe(() => { + // Iterate each matcher + if (hasValue(this.dynamicMatchers)) { + this.dynamicMatchers.forEach((matcher) => { + // Find the relation + const relation = this.dynamicFormRelationService.findRelationByMatcher((model as any).typeBindRelations, matcher); + // If the relation is defined, get matchesCondition result and pass it to the onChange event listener + if (relation !== undefined) { + const hasMatch = this.matchesCondition(relation, matcher); + matcher.onChange(hasMatch, model, control, this.injector); + } + }); + } + })); + } + }); + + return subscriptions; + } + + /** + * Helper function to construct a typeBindRelations array + * @param configuredTypeBindValues + */ + public getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: 'dc.type', + value: value + }); + }); + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html index bc41ade088..d518d59da2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.html @@ -1,6 +1,7 @@
@@ -13,7 +14,8 @@ cdkDrag cdkDragHandle [cdkDragDisabled]="dragDisabled" - [cdkDragPreviewClass]="'ds-submission-reorder-dragging'"> + [cdkDragPreviewClass]="'ds-submission-reorder-dragging'" + [class.grey-background]="model.isInlineGroupArray">
@@ -22,9 +24,11 @@
- - -
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts index 921b159718..01bba74cc8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.ts @@ -6,6 +6,7 @@ import { DynamicFormControlCustomEvent, DynamicFormControlEvent, DynamicFormControlLayout, + DynamicFormControlModel, DynamicFormLayout, DynamicFormLayoutService, DynamicFormValidationService, @@ -22,6 +23,8 @@ import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model'; }) export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent { + @Input() bindId = true; + @Input() formModel: DynamicFormControlModel[]; @Input() formLayout: DynamicFormLayout; @Input() group: FormGroup; @Input() layout: DynamicFormControlLayout; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index 1c053ffc80..5af9b2bd32 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -2,20 +2,29 @@ import { DynamicDateControlModel, DynamicDatePickerModelConfig, DynamicFormControlLayout, + DynamicFormControlModel, + DynamicFormControlRelation, serializable } from '@ng-dynamic-forms/core'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {isEmpty, isNotUndefined} from '../../../../../empty.util'; +import {MetadataValue} from '../../../../../../core/shared/metadata.models'; export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; export interface DynamicDsDateControlModelConfig extends DynamicDatePickerModelConfig { legend?: string; + typeBindRelations?: DynamicFormControlRelation[]; } /** * Dynamic Date Picker Model class */ export class DynamicDsDatePickerModel extends DynamicDateControlModel { + @serializable() hiddenUpdates: Subject; + @serializable() typeBindRelations: DynamicFormControlRelation[]; @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; + @serializable() metadataValue: MetadataValue; malformedDate: boolean; legend: string; hasLanguages = false; @@ -25,6 +34,23 @@ export class DynamicDsDatePickerModel extends DynamicDateControlModel { super(config, layout); this.malformedDate = false; this.legend = config.legend; + this.metadataValue = (config as any).metadataValue; + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.hiddenUpdates = new BehaviorSubject(this.hidden); + + // This was a subscription, then an async setTimeout, but it seems unnecessary + const parentModel = this.getRootParent(this); + if (parentModel && isNotUndefined(parentModel.hidden)) { + parentModel.hidden = this.hidden; + } + } + + private getRootParent(model: any): DynamicFormControlModel { + if (isEmpty(model) || isEmpty(model.parent)) { + return model; + } else { + return this.getRootParent(model.parent); + } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 290e29dc65..ed99acb34e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -1,14 +1,15 @@ import { DynamicFormControlLayout, + DynamicFormControlRelation, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { Subject } from 'rxjs'; +import {Subject} from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; import { VocabularyOptions } from '../../../../../core/submission/vocabularies/models/vocabulary-options.model'; -import { hasValue } from '../../../../empty.util'; +import {hasValue} from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../../models/relationship-options.model'; @@ -18,12 +19,14 @@ export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { language?: string; place?: number; value?: any; + typeBindRelations?: DynamicFormControlRelation[]; relationship?: RelationshipOptions; repeatable: boolean; metadataFields: string[]; submissionId: string; hasSelectableMetadata: boolean; metadataValue?: FormFieldMetadataValueObject; + isModelOfInnerForm?: boolean; } @@ -33,12 +36,17 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; + @serializable() place: number; + @serializable() typeBindRelations: DynamicFormControlRelation[]; + @serializable() typeBindHidden = false; @serializable() relationship?: RelationshipOptions; @serializable() repeatable?: boolean; @serializable() metadataFields: string[]; @serializable() submissionId: string; @serializable() hasSelectableMetadata: boolean; @serializable() metadataValue: FormFieldMetadataValueObject; + @serializable() isModelOfInnerForm: boolean; + constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); @@ -51,6 +59,8 @@ export class DsDynamicInputModel extends DynamicInputModel { this.submissionId = config.submissionId; this.hasSelectableMetadata = config.hasSelectableMetadata; this.metadataValue = config.metadataValue; + this.place = config.place; + this.isModelOfInnerForm = (hasValue(config.isModelOfInnerForm) ? config.isModelOfInnerForm : false); this.language = config.language; if (!this.language) { @@ -71,6 +81,8 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = lang; }); + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.vocabularyOptions = config.vocabularyOptions; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index d0b07de885..52364df45e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -1,5 +1,12 @@ -import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { + DynamicFormArrayModel, + DynamicFormArrayModelConfig, + DynamicFormControlLayout, + DynamicFormControlRelation, + serializable +} from '@ng-dynamic-forms/core'; import { RelationshipOptions } from '../../models/relationship-options.model'; +import { hasValue } from '../../../../empty.util'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { notRepeatable: boolean; @@ -10,6 +17,9 @@ export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig metadataFields: string[]; hasSelectableMetadata: boolean; isDraggable: boolean; + showButtons: boolean; + typeBindRelations?: DynamicFormControlRelation[]; + isInlineGroupArray?: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { @@ -21,17 +31,29 @@ export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() metadataFields: string[]; @serializable() hasSelectableMetadata: boolean; @serializable() isDraggable: boolean; + @serializable() showButtons = true; + @serializable() typeBindRelations: DynamicFormControlRelation[]; isRowArray = true; + isInlineGroupArray = false; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.notRepeatable = config.notRepeatable; - this.required = config.required; + if (hasValue(config.notRepeatable)) { + this.notRepeatable = config.notRepeatable; + } + if (hasValue(config.required)) { + this.required = config.required; + } + if (hasValue(config.showButtons)) { + this.showButtons = config.showButtons; + } this.submissionId = config.submissionId; this.relationshipConfig = config.relationshipConfig; this.metadataKey = config.metadataKey; this.metadataFields = config.metadataFields; this.hasSelectableMetadata = config.hasSelectableMetadata; this.isDraggable = config.isDraggable; + this.typeBindRelations = config.typeBindRelations ? config.typeBindRelations : []; + this.isInlineGroupArray = config.isInlineGroupArray ? config.isInlineGroupArray : false; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html index 843ed95530..8e1645633e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.html @@ -1,11 +1,17 @@ +
-
- - +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts index 789d5eb87c..9d8d73eab5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/form-group/dynamic-form-group.component.ts @@ -5,7 +5,9 @@ import { DynamicFormControlCustomEvent, DynamicFormControlEvent, DynamicFormControlLayout, - DynamicFormGroupModel, DynamicFormLayout, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, DynamicFormLayoutService, DynamicFormValidationService, DynamicTemplateDirective @@ -18,6 +20,7 @@ import { }) export class DsDynamicFormGroupComponent extends DynamicFormControlComponent { + @Input() formModel: DynamicFormControlModel[]; @Input() formLayout: DynamicFormLayout; @Input() group: FormGroup; @Input() layout: DynamicFormControlLayout; diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 4055c84921..ac9c2b652b 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -26,7 +26,7 @@ import { DynamicSliderModel, DynamicSwitchModel, DynamicTextAreaModel, - DynamicTimePickerModel + DynamicTimePickerModel, } from '@ng-dynamic-forms/core'; import { DynamicTagModel } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; import { DynamicListCheckboxGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model'; @@ -48,12 +48,18 @@ import { DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-conca import { DynamicLookupNameModel } from './ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import {ConfigurationDataService} from '../../../core/data/configuration-data.service'; +import {createSuccessfulRemoteDataObject$} from '../../remote-data.utils'; +import {ConfigurationProperty} from '../../../core/shared/configuration-property.model'; describe('FormBuilderService test suite', () => { let testModel: DynamicFormControlModel[]; let testFormConfiguration: SubmissionFormsModel; let service: FormBuilderService; + let configSpy: ConfigurationDataService; + const typeFieldProp = 'submit.type-bind.field'; + const typeFieldTestValue = 'dc.type'; const submissionId = '1234'; @@ -65,15 +71,24 @@ describe('FormBuilderService test suite', () => { return new Promise((resolve) => setTimeout(() => resolve(true), 0)); } - beforeEach(() => { + const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: typeFieldProp, + values: values, + }), + }); + beforeEach(() => { + configSpy = createConfigSuccessSpy(typeFieldTestValue); TestBed.configureTestingModule({ imports: [ReactiveFormsModule], providers: [ { provide: FormBuilderService, useClass: FormBuilderService }, { provide: DynamicFormValidationService, useValue: {} }, { provide: NG_VALIDATORS, useValue: testValidator, multi: true }, - { provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true } + { provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true }, + { provide: ConfigurationDataService, useValue: configSpy } ] }); @@ -197,7 +212,7 @@ describe('FormBuilderService test suite', () => { repeatable: false, metadataFields: [], submissionId: '1234', - hasSelectableMetadata: false + hasSelectableMetadata: false, }), new DynamicScrollableDropdownModel({ @@ -233,6 +248,7 @@ describe('FormBuilderService test suite', () => { hints: 'Enter the name of the author.', input: { type: 'onebox' }, label: 'Authors', + typeBind: [], languageCodes: [], mandatory: 'true', mandatoryMessage: 'Required field!', @@ -304,7 +320,9 @@ describe('FormBuilderService test suite', () => { required: false, metadataKey: 'dc.contributor.author', metadataFields: ['dc.contributor.author'], - hasSelectableMetadata: true + hasSelectableMetadata: true, + showButtons: true, + typeBindRelations: [{ match: 'VISIBLE', operator: 'OR', when: [{id: 'dc.type', value: 'Book' }]}] }, ), ]; @@ -424,7 +442,9 @@ describe('FormBuilderService test suite', () => { } as any; }); - beforeEach(inject([FormBuilderService], (formService: FormBuilderService) => service = formService)); + beforeEach(inject([FormBuilderService], (formService: FormBuilderService) => { + service = formService; + })); it('should find a dynamic form control model by id', () => { @@ -875,4 +895,12 @@ describe('FormBuilderService test suite', () => { expect(formArray.length === 0).toBe(true); }); + + it(`should request the ${typeFieldProp} property and set value "dc_type"`, () => { + const typeValue = service.getTypeField(); + expect(configSpy.findByPropertyName).toHaveBeenCalledTimes(1); + expect(configSpy.findByPropertyName).toHaveBeenCalledWith(typeFieldProp); + expect(typeValue).toEqual('dc_type'); + }); + }); diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 85d70f20dc..93a398a59d 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { AbstractControl, FormGroup } from '@angular/forms'; +import {Injectable, Optional} from '@angular/core'; +import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, @@ -7,6 +7,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_GROUP, DYNAMIC_FORM_CONTROL_TYPE_INPUT, DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP, + DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormComponentService, DynamicFormControlEvent, @@ -19,7 +20,15 @@ import { } from '@ng-dynamic-forms/core'; import { isObject, isString, mergeWith } from 'lodash'; -import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined, isNull } from '../../empty.util'; +import { + hasNoValue, + hasValue, + isEmpty, + isNotEmpty, + isNotNull, + isNotUndefined, + isNull +} from '../../empty.util'; import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; @@ -32,16 +41,61 @@ import { dateToString, isNgbDateStruct } from '../../date.util'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './ds-dynamic-form-ui/ds-dynamic-form-constants'; import { CONCAT_GROUP_SUFFIX, DynamicConcatModel } from './ds-dynamic-form-ui/models/ds-dynamic-concat.model'; import { VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; +import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; @Injectable() export class FormBuilderService extends DynamicFormService { + private typeBindModel: DynamicFormControlModel; + + /** + * This map contains the active forms model + */ + private formModels: Map; + + /** + * This map contains the active forms control groups + */ + private formGroups: Map; + + /** + * This is the field to use for type binding + */ + private typeField: string; + constructor( componentService: DynamicFormComponentService, validationService: DynamicFormValidationService, - protected rowParser: RowParser + protected rowParser: RowParser, + @Optional() protected configService: ConfigurationDataService, ) { super(componentService, validationService); + this.formModels = new Map(); + this.formGroups = new Map(); + // If optional config service was passed, perform an initial set of type field (default dc_type) for type binds + if (hasValue(this.configService)) { + this.setTypeBindFieldFromConfig(); + } + + + } + + createDynamicFormControlEvent(control: FormControl, group: FormGroup, model: DynamicFormControlModel, type: string): DynamicFormControlEvent { + const $event = { + value: (model as any).value, + autoSave: false + }; + const context: DynamicFormArrayGroupModel = (model?.parent instanceof DynamicFormArrayGroupModel) ? model?.parent : null; + return {$event, context, control: control, group: group, model: model, type}; + } + + getTypeBindModel() { + return this.typeBindModel; + } + + setTypeBindModel(model: DynamicFormControlModel) { + this.typeBindModel = model; } findById(id: string, groupModel: DynamicFormControlModel[], arrayIndex = null): DynamicFormControlModel | null { @@ -223,13 +277,15 @@ export class FormBuilderService extends DynamicFormService { return result; } - modelFromConfiguration(submissionId: string, json: string | SubmissionFormsModel, scopeUUID: string, sectionData: any = {}, submissionScope?: string, readOnly = false): DynamicFormControlModel[] | never { - let rows: DynamicFormControlModel[] = []; - const rawData = typeof json === 'string' ? JSON.parse(json, parseReviver) : json; - + modelFromConfiguration(submissionId: string, json: string | SubmissionFormsModel, scopeUUID: string, sectionData: any = {}, + submissionScope?: string, readOnly = false, typeBindModel = null, + isInnerForm = false): DynamicFormControlModel[] | never { + let rows: DynamicFormControlModel[] = []; + const rawData = typeof json === 'string' ? JSON.parse(json, parseReviver) : json; if (rawData.rows && !isEmpty(rawData.rows)) { rawData.rows.forEach((currentRow) => { - const rowParsed = this.rowParser.parse(submissionId, currentRow, scopeUUID, sectionData, submissionScope, readOnly); + const rowParsed = this.rowParser.parse(submissionId, currentRow, scopeUUID, sectionData, submissionScope, + readOnly, this.getTypeField()); if (isNotNull(rowParsed)) { if (Array.isArray(rowParsed)) { rows = rows.concat(rowParsed); @@ -240,6 +296,13 @@ export class FormBuilderService extends DynamicFormService { }); } + if (hasNoValue(typeBindModel)) { + typeBindModel = this.findById(this.typeField, rows); + } + + if (hasValue(typeBindModel)) { + this.setTypeBindModel(typeBindModel); + } return rows; } @@ -309,6 +372,10 @@ export class FormBuilderService extends DynamicFormService { return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; } + getFormControlByModel(formGroup: FormGroup, fieldModel: DynamicFormControlModel): AbstractControl { + return isNotEmpty(fieldModel) ? formGroup.get(this.getPath(fieldModel)) : null; + } + /** * Note (discovered while debugging) this is not the ID as used in the form, * but the first part of the path needed in a patch operation: @@ -328,6 +395,35 @@ export class FormBuilderService extends DynamicFormService { return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id; } + /** + * If present, remove form model from formModels map + * @param id id of model + */ + removeFormModel(id: string): void { + if (this.formModels.has(id)) { + this.formModels.delete(id); + } + } + + /** + * Add new form model to formModels map + * @param id id of model + * @param formGroup FormGroup + */ + addFormGroups(id: string, formGroup: FormGroup): void { + this.formGroups.set(id, formGroup); + } + + /** + * If present, remove form model from formModels map + * @param id id of model + */ + removeFormGroup(id: string): void { + if (this.formGroups.has(id)) { + this.formGroups.delete(id); + } + } + /** * Calculate the metadata list related to the event. * @param event @@ -400,4 +496,39 @@ export class FormBuilderService extends DynamicFormService { return Object.keys(result); } + /** + * Get the type bind field from config + */ + setTypeBindFieldFromConfig(): void { + this.configService.findByPropertyName('submit.type-bind.field').pipe( + getFirstCompletedRemoteData(), + ).subscribe((remoteData: any) => { + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { + this.typeField = 'dc_type'; + return; + } + // Read type bind value from response and set if non-empty + const typeFieldConfig = remoteData.payload.values[0]; + if (isEmpty(typeFieldConfig)) { + this.typeField = 'dc_type'; + } else { + this.typeField = typeFieldConfig.replace(/\./g, '_'); + } + }); + } + + /** + * Get type field. If the type isn't already set, and a ConfigurationDataService is provided, set (with subscribe) + * from back end. Otherwise, get/set a default "dc_type" value + */ + getTypeField(): string { + if (hasValue(this.configService) && hasNoValue(this.typeField)) { + this.setTypeBindFieldFromConfig(); + } else if (hasNoValue(this.typeField)) { + this.typeField = 'dc_type'; + } + return this.typeField; + } + } diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts index 95ee980aeb..be3150bae3 100644 --- a/src/app/shared/form/builder/models/form-field.model.ts +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -113,6 +113,12 @@ export class FormFieldModel { @autoserialize style: string; + /** + * Containing types to bind for this field + */ + @autoserialize + typeBind: string[]; + /** * Containing the value for this metadata field */ diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 8085924422..2de5f256dc 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -1,4 +1,4 @@ -import { Inject } from '@angular/core'; +import {Inject} from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts index b9adf3ed65..9ab43709ad 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('DateFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts index aef0219579..c67c2c7695 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -1,7 +1,7 @@ import { FieldParser } from './field-parser'; import { - DynamicDsDateControlModelConfig, - DynamicDsDatePickerModel + DynamicDsDatePickerModel, + DynamicDsDateControlModelConfig } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; import { isNotEmpty } from '../../../empty.util'; import { DS_DATE_PICKER_SEPARATOR } from '../ds-dynamic-form-ui/models/date-picker/date-picker.component'; @@ -13,7 +13,7 @@ export class DateFieldParser extends FieldParser { let malformedDate = false; const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, false, true); inputDateModelConfig.legend = this.configData.label; - + inputDateModelConfig.disabled = inputDateModelConfig.readOnly; inputDateModelConfig.toggleIcon = 'fas fa-calendar'; this.setValues(inputDateModelConfig as any, fieldValue); // Init Data and validity check diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts index e3e86d7051..d69f0e48e9 100644 --- a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -11,7 +11,8 @@ describe('DisabledFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts index 82d2aeac63..3dca7558b3 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts @@ -11,7 +11,8 @@ describe('DropdownFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 760fc63482..3e5ec0b9da 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -1,4 +1,4 @@ -import { Inject } from '@angular/core'; +import {Inject} from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { CONFIG_DATA, @@ -22,7 +22,7 @@ export class DropdownFieldParser extends FieldParser { @Inject(SUBMISSION_ID) submissionId: string, @Inject(CONFIG_DATA) configData: FormFieldModel, @Inject(INIT_FORM_VALUES) initFormValues, - @Inject(PARSER_OPTIONS) parserOptions: ParserOptions + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions, ) { super(submissionId, configData, initFormValues, parserOptions); } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index da304ca267..bd6820d4b3 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,7 +1,7 @@ -import { Inject, InjectionToken } from '@angular/core'; +import {Inject, InjectionToken} from '@angular/core'; import { uniqueId } from 'lodash'; -import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; +import {DynamicFormControlLayout, DynamicFormControlRelation, MATCH_VISIBLE, OR_OPERATOR} from '@ng-dynamic-forms/core'; import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; import { FormFieldModel } from '../models/form-field.model'; @@ -26,6 +26,11 @@ export const PARSER_OPTIONS: InjectionToken = new InjectionToken< export abstract class FieldParser { protected fieldId: string; + /** + * This is the field to use for type binding + * @protected + */ + protected typeField: string; constructor( @Inject(SUBMISSION_ID) protected submissionId: string, @@ -67,6 +72,8 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, + typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, + this.parserOptions.typeField) : null, groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -275,7 +282,7 @@ export abstract class FieldParser { // Set label this.setLabel(controlModel, label); if (hint) { - controlModel.hint = this.configData.hints; + controlModel.hint = this.configData.hints || ' '; } controlModel.placeholder = this.configData.label; @@ -292,9 +299,46 @@ export abstract class FieldParser { (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; } + // If typeBind is configured + if (isNotEmpty(this.configData.typeBind)) { + (controlModel as DsDynamicInputModel).typeBindRelations = this.getTypeBindRelations(this.configData.typeBind, + this.parserOptions.typeField); + } + return controlModel; } + /** + * Get the type bind values from the REST data for a specific field + * The return value is any[] in the method signature but in reality it's + * returning the 'relation' that'll be used for a dynamic matcher when filtering + * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' + * (OR) and a 'when' condition (the bindValues array). + * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) + * @private + * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field + */ + private getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: typeField, + value: value + }); + }); + // match: MATCH_VISIBLE means that if true, the field / component will be visible + // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND + // when: the list of values to match against, in this case the list of strings from ... + // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part + // + // The opposing match value will be the dc.type for the workspace item + return [{ + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues + }]; + } + protected hasRegex() { return hasValue(this.configData.input.regex); } diff --git a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts index 8a05b169fd..30d1913a51 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts @@ -13,7 +13,8 @@ describe('ListFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts index 87cee9d950..24efcf3462 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('LookupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts index 3d02b6952e..d0281681ef 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('LookupNameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts index 514585f03f..6b520142cc 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -14,7 +14,8 @@ describe('NameFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts index 8ecce24194..e7e68a6461 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -15,7 +15,8 @@ describe('OneboxFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/parser-options.ts b/src/app/shared/form/builder/parsers/parser-options.ts index 8b0b42008e..f7aac3449d 100644 --- a/src/app/shared/form/builder/parsers/parser-options.ts +++ b/src/app/shared/form/builder/parsers/parser-options.ts @@ -2,4 +2,5 @@ export interface ParserOptions { readOnly: boolean; submissionScope: string; collectionUUID: string; + typeField: string; } diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts index 111193a637..7d48ad2d00 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('RelationGroupFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: 'WORKSPACE' + collectionUUID: 'WORKSPACE', + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/row-parser.spec.ts b/src/app/shared/form/builder/parsers/row-parser.spec.ts index e612534d55..1f9bde8a7f 100644 --- a/src/app/shared/form/builder/parsers/row-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/row-parser.spec.ts @@ -22,6 +22,7 @@ describe('RowParser test suite', () => { const initFormValues = {}; const submissionScope = 'WORKSPACE'; const readOnly = false; + const typeField = 'dc_type'; beforeEach(() => { row1 = { @@ -338,7 +339,7 @@ describe('RowParser test suite', () => { it('should return a DynamicRowGroupModel object', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel instanceof DynamicRowGroupModel).toBe(true); }); @@ -346,7 +347,7 @@ describe('RowParser test suite', () => { it('should return a row with three fields', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect((rowModel as DynamicRowGroupModel).group.length).toBe(3); }); @@ -354,7 +355,7 @@ describe('RowParser test suite', () => { it('should return a DynamicRowArrayModel object', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row2, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row2, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel instanceof DynamicRowArrayModel).toBe(true); }); @@ -362,7 +363,7 @@ describe('RowParser test suite', () => { it('should return a row that contains only scoped fields', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row3, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row3, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect((rowModel as DynamicRowGroupModel).group.length).toBe(1); }); @@ -370,7 +371,7 @@ describe('RowParser test suite', () => { it('should be able to parse a dropdown combo field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row4, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row4, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -378,7 +379,7 @@ describe('RowParser test suite', () => { it('should be able to parse a lookup-name field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row5, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row5, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -386,7 +387,7 @@ describe('RowParser test suite', () => { it('should be able to parse a list field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row6, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row6, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -394,7 +395,7 @@ describe('RowParser test suite', () => { it('should be able to parse a date field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row7, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row7, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -402,7 +403,7 @@ describe('RowParser test suite', () => { it('should be able to parse a tag field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row8, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row8, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -410,7 +411,7 @@ describe('RowParser test suite', () => { it('should be able to parse a textarea field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row9, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row9, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); @@ -418,7 +419,7 @@ describe('RowParser test suite', () => { it('should be able to parse a group field', () => { const parser = new RowParser(undefined); - const rowModel = parser.parse(submissionId, row10, scopeUUID, initFormValues, submissionScope, readOnly); + const rowModel = parser.parse(submissionId, row10, scopeUUID, initFormValues, submissionScope, readOnly, typeField); expect(rowModel).toBeDefined(); }); diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index fe664305b0..764f52ffdf 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -31,7 +31,8 @@ export class RowParser { scopeUUID, initFormValues: any, submissionScope, - readOnly: boolean): DynamicRowGroupModel { + readOnly: boolean, + typeField: string): DynamicRowGroupModel { let fieldModel: any = null; let parsedResult = null; const config: DynamicFormGroupModelConfig = { @@ -47,7 +48,8 @@ export class RowParser { const parserOptions: ParserOptions = { readOnly: readOnly, submissionScope: submissionScope, - collectionUUID: scopeUUID + collectionUUID: scopeUUID, + typeField: typeField }; // Iterate over row's fields diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts index b044f43833..0761cfe60e 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('SeriesFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts index 7c63235f67..115829f8d3 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('TagFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts index a81907aa13..855e464f21 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -12,7 +12,8 @@ describe('TextareaFieldParser test suite', () => { const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, - collectionUUID: null + collectionUUID: null, + typeField: 'dc_type' }; beforeEach(() => { diff --git a/src/app/shared/handle.service.spec.ts b/src/app/shared/handle.service.spec.ts new file mode 100644 index 0000000000..b326eb0416 --- /dev/null +++ b/src/app/shared/handle.service.spec.ts @@ -0,0 +1,47 @@ +import { HandleService } from './handle.service'; + +describe('HandleService', () => { + let service: HandleService; + + beforeEach(() => { + service = new HandleService(); + }); + + describe(`normalizeHandle`, () => { + it(`should simply return an already normalized handle`, () => { + let input, output; + + input = '123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual(input); + + input = '12.3456.789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual(input); + }); + + it(`should normalize a handle url`, () => { + let input, output; + + input = 'https://hdl.handle.net/handle/123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual('123456789/123456'); + + input = 'https://rest.api/server/handle/123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual('123456789/123456'); + }); + + it(`should return null if the input doesn't contain a handle`, () => { + let input, output; + + input = 'https://hdl.handle.net/handle/123456789'; + output = service.normalizeHandle(input); + expect(output).toBeNull(); + + input = 'something completely different'; + output = service.normalizeHandle(input); + expect(output).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/handle.service.ts b/src/app/shared/handle.service.ts new file mode 100644 index 0000000000..da0f17f7de --- /dev/null +++ b/src/app/shared/handle.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { isNotEmpty, isEmpty } from './empty.util'; + +const PREFIX_REGEX = /handle\/([^\/]+\/[^\/]+)$/; +const NO_PREFIX_REGEX = /^([^\/]+\/[^\/]+)$/; + +@Injectable({ + providedIn: 'root' +}) +export class HandleService { + + + /** + * Turns a handle string into the default 123456789/12345 format + * + * @param handle the input handle + * + * normalizeHandle('123456789/123456') // '123456789/123456' + * normalizeHandle('12.3456.789/123456') // '12.3456.789/123456' + * normalizeHandle('https://hdl.handle.net/handle/123456789/123456') // '123456789/123456' + * normalizeHandle('https://rest.api/server/handle/123456789/123456') // '123456789/123456' + * normalizeHandle('https://rest.api/server/handle/123456789') // null + */ + normalizeHandle(handle: string): string { + let matches: string[]; + if (isNotEmpty(handle)) { + matches = handle.match(PREFIX_REGEX); + } + + if (isEmpty(matches) || matches.length < 2) { + matches = handle.match(NO_PREFIX_REGEX); + } + + if (isEmpty(matches) || matches.length < 2) { + return null; + } else { + return matches[1]; + } + } + +} diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 847bf6ac4f..7ea0b96d5b 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -46,7 +46,7 @@ describe('IdleModalComponent', () => { describe('extendSessionPressed', () => { beforeEach(fakeAsync(() => { - spyOn(component.response, 'next'); + spyOn(component.response, 'emit'); component.extendSessionPressed(); })); it('should set idle to false', () => { @@ -55,8 +55,8 @@ describe('IdleModalComponent', () => { it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); - it('response \'closed\' should have true as next', () => { - expect(component.response.next).toHaveBeenCalledWith(true); + it('response \'closed\' should emit true', () => { + expect(component.response.emit).toHaveBeenCalledWith(true); }); }); @@ -74,7 +74,7 @@ describe('IdleModalComponent', () => { describe('closePressed', () => { beforeEach(fakeAsync(() => { - spyOn(component.response, 'next'); + spyOn(component.response, 'emit'); component.closePressed(); })); it('should set idle to false', () => { @@ -83,8 +83,8 @@ describe('IdleModalComponent', () => { it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); - it('response \'closed\' should have true as next', () => { - expect(component.response.next).toHaveBeenCalledWith(true); + it('response \'closed\' should emit true', () => { + expect(component.response.emit).toHaveBeenCalledWith(true); }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index 35fafcf5cf..4873137ff1 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -1,8 +1,7 @@ -import { Component, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { environment } from '../../../environments/environment'; import { AuthService } from '../../core/auth/auth.service'; -import { Subject } from 'rxjs'; import { hasValue } from '../empty.util'; import { Store } from '@ngrx/store'; import { AppState } from '../../app.reducer'; @@ -29,7 +28,7 @@ export class IdleModalComponent implements OnInit { * An event fired when the modal is closed */ @Output() - response: Subject = new Subject(); + response = new EventEmitter(); constructor(private activeModal: NgbActiveModal, private authService: AuthService, @@ -84,6 +83,6 @@ export class IdleModalComponent implements OnInit { */ closeModal() { this.activeModal.close(); - this.response.next(true); + this.response.emit(true); } } diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index 3b150a46c9..726dc9ca0e 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -27,7 +27,7 @@ + + + + + diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts index 31bb3078c0..23ee62e628 100644 --- a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts @@ -1,16 +1,19 @@ -import { Component, EventEmitter, Output } from '@angular/core'; +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { ModalBeforeDismiss } from '../../../interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-item-versions-summary-modal', templateUrl: './item-versions-summary-modal.component.html', styleUrls: ['./item-versions-summary-modal.component.scss'] }) -export class ItemVersionsSummaryModalComponent { +export class ItemVersionsSummaryModalComponent implements OnInit, ModalBeforeDismiss { versionNumber: number; newVersionSummary: string; firstVersion = true; + submitted$: BehaviorSubject; @Output() createVersionEvent: EventEmitter = new EventEmitter(); @@ -19,13 +22,24 @@ export class ItemVersionsSummaryModalComponent { ) { } + ngOnInit() { + this.submitted$ = new BehaviorSubject(false); + } + onModalClose() { this.activeModal.dismiss(); } + beforeDismiss(): boolean | Promise { + // prevent the modal from being dismissed after version creation is initiated + return !this.submitted$.getValue(); + } + onModalSubmit() { this.createVersionEvent.emit(this.newVersionSummary); - this.activeModal.close(); + this.submitted$.next(true); + // NOTE: the caller of this modal is responsible for closing it, + // e.g. after the version creation POST request completed. } } diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts index 8bb5554b77..d4dbc336ab 100644 --- a/src/app/shared/item/item-versions/item-versions.component.spec.ts +++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts @@ -1,14 +1,15 @@ import { ItemVersionsComponent } from './item-versions.component'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, TestBed, waitForAsync +} from '@angular/core/testing'; import { VarDirective } from '../../utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { Version } from '../../../core/shared/version.model'; import { VersionHistory } from '../../../core/shared/version-history.model'; import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; -import { By } from '@angular/platform-browser'; +import { BrowserModule, By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; import { EMPTY, of, of as observableOf } from 'rxjs'; @@ -17,7 +18,7 @@ import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { AuthService } from '../../../core/auth/auth.service'; import { VersionDataService } from '../../../core/data/version-data.service'; import { ItemDataService } from '../../../core/data/item-data.service'; -import { FormBuilder } from '@angular/forms'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; @@ -25,6 +26,9 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; +import { Router } from '@angular/router'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { CommonModule } from '@angular/common'; describe('ItemVersionsComponent', () => { let component: ItemVersionsComponent; @@ -70,6 +74,7 @@ describe('ItemVersionsComponent', () => { versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); const item1 = Object.assign(new Item(), { // is a workspace item + id: 'item-identifier-1', uuid: 'item-identifier-1', handle: '123456789/1', version: createSuccessfulRemoteDataObject$(version1), @@ -80,6 +85,7 @@ describe('ItemVersionsComponent', () => { } }); const item2 = Object.assign(new Item(), { + id: 'item-identifier-2', uuid: 'item-identifier-2', handle: '123456789/2', version: createSuccessfulRemoteDataObject$(version2), @@ -95,12 +101,16 @@ describe('ItemVersionsComponent', () => { const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)), + getVersionHistoryFromVersion$: of(versionHistory), + getLatestVersionItemFromHistory$: of(item1), // called when version2 is deleted }); const authenticationServiceSpy = jasmine.createSpyObj('authenticationService', { isAuthenticated: observableOf(true), setRedirectUrl: {} }); - const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); const workspaceItemDataServiceSpy = jasmine.createSpyObj('workspaceItemDataService', { findByItem: EMPTY, }); @@ -115,11 +125,19 @@ describe('ItemVersionsComponent', () => { findByPropertyName: of(true), }); + const itemDataServiceSpy = jasmine.createSpyObj('itemDataService', { + delete: createSuccessfulRemoteDataObject$({}), + }); + + const routerSpy = jasmine.createSpyObj('router', { + navigateByUrl: null, + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ItemVersionsComponent, VarDirective], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + imports: [TranslateModule.forRoot(), CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule], providers: [ {provide: PaginationService, useValue: new PaginationServiceStub()}, {provide: FormBuilder, useValue: new FormBuilder()}, @@ -127,11 +145,12 @@ describe('ItemVersionsComponent', () => { {provide: AuthService, useValue: authenticationServiceSpy}, {provide: AuthorizationDataService, useValue: authorizationServiceSpy}, {provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy}, - {provide: ItemDataService, useValue: {}}, + {provide: ItemDataService, useValue: itemDataServiceSpy}, {provide: VersionDataService, useValue: versionServiceSpy}, {provide: WorkspaceitemDataService, useValue: workspaceItemDataServiceSpy}, {provide: WorkflowItemDataService, useValue: workflowItemDataServiceSpy}, {provide: ConfigurationDataService, useValue: configurationServiceSpy}, + { provide: Router, useValue: routerSpy }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -275,4 +294,43 @@ describe('ItemVersionsComponent', () => { }); }); + describe('when deleting a version', () => { + let deleteButton; + + beforeEach(() => { + const canDelete = (featureID: FeatureID, url: string ) => of(featureID === FeatureID.CanDeleteVersion); + authorizationServiceSpy.isAuthorized.and.callFake(canDelete); + + fixture.detectChanges(); + + // delete the last version in the table (version2 → item2) + deleteButton = fixture.debugElement.queryAll(By.css('.version-row-element-delete'))[1].nativeElement; + + itemDataServiceSpy.delete.calls.reset(); + }); + + describe('if confirmed via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .confirm').click(); + })); + + it('should call ItemService.delete', () => { + expect(itemDataServiceSpy.delete).toHaveBeenCalledWith(item2.id); + }); + }); + + describe('if canceled via modal', () => { + beforeEach(waitForAsync(() => { + deleteButton.click(); + fixture.detectChanges(); + (document as any).querySelector('.modal-footer .cancel').click(); + })); + + it('should not call ItemService.delete', () => { + expect(itemDataServiceSpy.delete).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts index 2457cf76c4..b7b8182658 100644 --- a/src/app/shared/item/item-versions/item-versions.component.ts +++ b/src/app/shared/item/item-versions/item-versions.component.ts @@ -283,44 +283,42 @@ export class ItemVersionsComponent implements OnInit { activeModal.componentInstance.firstVersion = false; // On modal submit/dismiss - activeModal.result.then(() => { - versionItem$.pipe( - getFirstSucceededRemoteDataPayload(), - // Retrieve version history and invalidate cache - mergeMap((item: Item) => combineLatest([ - of(item), - this.versionHistoryService.getVersionHistoryFromVersion$(version).pipe( - tap((versionHistory: VersionHistory) => { - this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); - }) - ) - ])), - // Delete item - mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([ - this.deleteItemAndGetResult$(item), - of(versionHistory) - ])), - // Retrieve new latest version - mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([ - of(deleteItemResult), - this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe( - tap(() => { - this.getAllVersions(of(versionHistory)); - }), - ) - ])), - ).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => { - // Notify operation result and redirect to latest item - if (deleteHasSucceeded) { - this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber})); - } else { - this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber})); - } - if (redirectToLatest) { - const path = getItemEditVersionhistoryRoute(newLatestVersionItem); - this.router.navigateByUrl(path); - } - }); + activeModal.componentInstance.response.pipe(take(1)).subscribe((ok) => { + if (ok) { + versionItem$.pipe( + getFirstSucceededRemoteDataPayload(), + // Retrieve version history + mergeMap((item: Item) => combineLatest([ + of(item), + this.versionHistoryService.getVersionHistoryFromVersion$(version) + ])), + // Delete item + mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([ + this.deleteItemAndGetResult$(item), + of(versionHistory) + ])), + // Retrieve new latest version + mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([ + of(deleteItemResult), + this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe( + tap(() => { + this.getAllVersions(of(versionHistory)); + }), + ) + ])), + ).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => { + // Notify operation result and redirect to latest item + if (deleteHasSucceeded) { + this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber})); + } else { + this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber})); + } + if (redirectToLatest) { + const path = getItemEditVersionhistoryRoute(newLatestVersionItem); + this.router.navigateByUrl(path); + } + }); + } }); } @@ -342,6 +340,9 @@ export class ItemVersionsComponent implements OnInit { version.item.pipe(getFirstSucceededRemoteDataPayload()) ])), mergeMap(([summary, item]: [string, Item]) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + getFirstCompletedRemoteData(), + // close model (should be displaying loading/waiting indicator) when version creation failed/succeeded + tap(() => activeModal.close()), // show success/failure notification tap((newVersionRD: RemoteData) => { this.itemVersionShared.notifyCreateNewVersion(newVersionRD); diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 448c7e9372..0da4fc4e21 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -8,6 +8,6 @@ - {{"login.form.new-user" | translate}} - {{"login.form.forgot-password" | translate}} + {{"login.form.new-user" | translate}} + {{"login.form.forgot-password" | translate}}
diff --git a/src/app/shared/log-in/methods/log-in-external-provider.component.ts b/src/app/shared/log-in/methods/log-in-external-provider.component.ts new file mode 100644 index 0000000000..037fc40e90 --- /dev/null +++ b/src/app/shared/log-in/methods/log-in-external-provider.component.ts @@ -0,0 +1,110 @@ +import { Component, Inject, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { select, Store } from '@ngrx/store'; + +import { AuthMethod } from '../../../core/auth/models/auth.method'; + +import { isAuthenticated, isAuthenticationLoading } from '../../../core/auth/selectors'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; +import { isEmpty, isNotNull } from '../../empty.util'; +import { AuthService } from '../../../core/auth/auth.service'; +import { HardRedirectService } from '../../../core/services/hard-redirect.service'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { CoreState } from '../../../core/core-state.model'; + +@Component({ + selector: 'ds-log-in-external-provider', + template: '' + +}) +export abstract class LogInExternalProviderComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * The shibboleth authentication location url. + * @type {string} + */ + public location: string; + + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {boolean} isStandalonePage + * @param {NativeWindowRef} _window + * @param {AuthService} authService + * @param {HardRedirectService} hardRedirectService + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + @Inject('isStandalonePage') public isStandalonePage: boolean, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private authService: AuthService, + private hardRedirectService: HardRedirectService, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + ngOnInit(): void { + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + + // set loading + this.loading = this.store.pipe(select(isAuthenticationLoading)); + + // set location + this.location = decodeURIComponent(this.injectedAuthMethodModel.location); + + } + + /** + * Redirect to the external provider url for login + */ + redirectToExternalProvider() { + this.authService.getRedirectUrl().pipe(take(1)).subscribe((redirectRoute) => { + if (!this.isStandalonePage) { + redirectRoute = this.hardRedirectService.getCurrentRoute(); + } else if (isEmpty(redirectRoute)) { + redirectRoute = '/'; + } + const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString(); + + let externalServerUrl = this.location; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(this.location); + const redirectUrlFromServer = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; + externalServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + // redirect to shibboleth authentication url + this.hardRedirectService.redirect(externalServerUrl); + }); + + } + +} diff --git a/src/app/shared/log-in/methods/oidc/log-in-oidc.component.ts b/src/app/shared/log-in/methods/oidc/log-in-oidc.component.ts index 38cedf91ec..882996b207 100644 --- a/src/app/shared/log-in/methods/oidc/log-in-oidc.component.ts +++ b/src/app/shared/log-in/methods/oidc/log-in-oidc.component.ts @@ -1,110 +1,21 @@ -import { Component, Inject, OnInit, } from '@angular/core'; - -import { Observable } from 'rxjs'; -import { select, Store } from '@ngrx/store'; +import { Component, } from '@angular/core'; import { renderAuthMethodFor } from '../log-in.methods-decorator'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; -import { AuthMethod } from '../../../../core/auth/models/auth.method'; - -import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; -import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; -import { isNotNull, isEmpty } from '../../../empty.util'; -import { AuthService } from '../../../../core/auth/auth.service'; -import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; -import { take } from 'rxjs/operators'; -import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; -import { CoreState } from '../../../../core/core-state.model'; +import { LogInExternalProviderComponent } from '../log-in-external-provider.component'; @Component({ selector: 'ds-log-in-oidc', templateUrl: './log-in-oidc.component.html', }) @renderAuthMethodFor(AuthMethodType.Oidc) -export class LogInOidcComponent implements OnInit { +export class LogInOidcComponent extends LogInExternalProviderComponent { /** - * The authentication method data. - * @type {AuthMethod} + * Redirect to orcid authentication url */ - public authMethod: AuthMethod; - - /** - * True if the authentication is loading. - * @type {boolean} - */ - public loading: Observable; - - /** - * The oidc authentication location url. - * @type {string} - */ - public location: string; - - /** - * Whether user is authenticated. - * @type {Observable} - */ - public isAuthenticated: Observable; - - /** - * @constructor - * @param {AuthMethod} injectedAuthMethodModel - * @param {boolean} isStandalonePage - * @param {NativeWindowRef} _window - * @param {AuthService} authService - * @param {HardRedirectService} hardRedirectService - * @param {Store} store - */ - constructor( - @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, - @Inject('isStandalonePage') public isStandalonePage: boolean, - @Inject(NativeWindowService) protected _window: NativeWindowRef, - private authService: AuthService, - private hardRedirectService: HardRedirectService, - private store: Store - ) { - this.authMethod = injectedAuthMethodModel; - } - - ngOnInit(): void { - // set isAuthenticated - this.isAuthenticated = this.store.pipe(select(isAuthenticated)); - - // set loading - this.loading = this.store.pipe(select(isAuthenticationLoading)); - - // set location - this.location = decodeURIComponent(this.injectedAuthMethodModel.location); - - } - redirectToOidc() { - - this.authService.getRedirectUrl().pipe(take(1)).subscribe((redirectRoute) => { - if (!this.isStandalonePage) { - redirectRoute = this.hardRedirectService.getCurrentRoute(); - } else if (isEmpty(redirectRoute)) { - redirectRoute = '/'; - } - const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString(); - - let oidcServerUrl = this.location; - const myRegexp = /\?redirectUrl=(.*)/g; - const match = myRegexp.exec(this.location); - const redirectUrlFromServer = (match && match[1]) ? match[1] : null; - - // Check whether the current page is different from the redirect url received from rest - if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { - // change the redirect url with the current page url - const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; - oidcServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); - } - - // redirect to oidc authentication url - this.hardRedirectService.redirect(oidcServerUrl); - }); - + this.redirectToExternalProvider(); } } diff --git a/src/app/shared/log-in/methods/orcid/log-in-orcid.component.html b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.html new file mode 100644 index 0000000000..6f5453fd60 --- /dev/null +++ b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/app/shared/log-in/methods/orcid/log-in-orcid.component.spec.ts b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.spec.ts new file mode 100644 index 0000000000..001f0a4959 --- /dev/null +++ b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.spec.ts @@ -0,0 +1,155 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { provideMockStore } from '@ngrx/store/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson.mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service.stub'; +import { storeModuleConfig } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { LogInOrcidComponent } from './log-in-orcid.component'; +import { NativeWindowService } from '../../../../core/services/window.service'; +import { RouterStub } from '../../../testing/router.stub'; +import { ActivatedRouteStub } from '../../../testing/active-router.stub'; +import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref'; +import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; + + +describe('LogInOrcidComponent', () => { + + let component: LogInOrcidComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + let componentAsAny: any; + let setHrefSpy; + let orcidBaseUrl; + let location; + let initialState: any; + let hardRedirectService: HardRedirectService; + + beforeEach(() => { + user = EPersonMock; + orcidBaseUrl = 'dspace-rest.test/orcid?redirectUrl='; + location = orcidBaseUrl + 'http://dspace-angular.test/home'; + + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + getCurrentRoute: {}, + redirect: {} + }); + + initialState = { + core: { + auth: { + authenticated: false, + loaded: false, + blocking: false, + loading: false, + authMethods: [] + } + } + }; + }); + + beforeEach(waitForAsync(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig), + TranslateModule.forRoot() + ], + declarations: [ + LogInOrcidComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Orcid, location) }, + { provide: 'isStandalonePage', useValue: true }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: HardRedirectService, useValue: hardRedirectService }, + provideMockStore({ initialState }), + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(() => { + // create component and test fixture + fixture = TestBed.createComponent(LogInOrcidComponent); + + // get test component from the fixture + component = fixture.componentInstance; + componentAsAny = component; + + // create page + page = new Page(component, fixture); + setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough(); + + }); + + it('should set the properly a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/collections/12345'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToOrcid(); + + expect(setHrefSpy).toHaveBeenCalledWith(currentUrl); + + }); + + it('should not set a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/home'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToOrcid(); + + expect(setHrefSpy).toHaveBeenCalledWith(currentUrl); + + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInOrcidComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + +} diff --git a/src/app/shared/log-in/methods/orcid/log-in-orcid.component.ts b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.ts new file mode 100644 index 0000000000..e0b1da3db5 --- /dev/null +++ b/src/app/shared/log-in/methods/orcid/log-in-orcid.component.ts @@ -0,0 +1,21 @@ +import { Component, } from '@angular/core'; + +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { LogInExternalProviderComponent } from '../log-in-external-provider.component'; + +@Component({ + selector: 'ds-log-in-orcid', + templateUrl: './log-in-orcid.component.html', +}) +@renderAuthMethodFor(AuthMethodType.Orcid) +export class LogInOrcidComponent extends LogInExternalProviderComponent { + + /** + * Redirect to orcid authentication url + */ + redirectToOrcid() { + this.redirectToExternalProvider(); + } + +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.html b/src/app/shared/log-in/methods/password/log-in-password.component.html index 89e7b9ff1a..c1f1016cb8 100644 --- a/src/app/shared/log-in/methods/password/log-in-password.component.html +++ b/src/app/shared/log-in/methods/password/log-in-password.component.html @@ -10,7 +10,7 @@ placeholder="{{'login.form.email' | translate}}" required type="email" - data-test="email"> + [attr.data-test]="'email' | dsBrowserOnly"> + [attr.data-test]="'password' | dsBrowserOnly"> - diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts index 355d328f3f..5238482770 100644 --- a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts +++ b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts @@ -17,6 +17,7 @@ import { storeModuleConfig } from '../../../../app.reducer'; import { AuthMethod } from '../../../../core/auth/models/auth.method'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; +import { BrowserOnlyMockPipe } from '../../../testing/browser-only-mock.pipe'; describe('LogInPasswordComponent', () => { @@ -57,7 +58,8 @@ describe('LogInPasswordComponent', () => { TranslateModule.forRoot() ], declarations: [ - LogInPasswordComponent + LogInPasswordComponent, + BrowserOnlyMockPipe, ], providers: [ { provide: AuthService, useClass: AuthServiceStub }, diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts index d218a7ca4e..dcfb3ccfc3 100644 --- a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts @@ -1,21 +1,8 @@ -import { Component, Inject, OnInit, } from '@angular/core'; - -import { Observable } from 'rxjs'; -import { select, Store } from '@ngrx/store'; +import { Component, } from '@angular/core'; import { renderAuthMethodFor } from '../log-in.methods-decorator'; import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; -import { AuthMethod } from '../../../../core/auth/models/auth.method'; - -import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; -import { RouteService } from '../../../../core/services/route.service'; -import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; -import { isNotNull, isEmpty } from '../../../empty.util'; -import { AuthService } from '../../../../core/auth/auth.service'; -import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; -import { take } from 'rxjs/operators'; -import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; -import { CoreState } from '../../../../core/core-state.model'; +import { LogInExternalProviderComponent } from '../log-in-external-provider.component'; @Component({ selector: 'ds-log-in-shibboleth', @@ -24,92 +11,13 @@ import { CoreState } from '../../../../core/core-state.model'; }) @renderAuthMethodFor(AuthMethodType.Shibboleth) -export class LogInShibbolethComponent implements OnInit { +export class LogInShibbolethComponent extends LogInExternalProviderComponent { /** - * The authentication method data. - * @type {AuthMethod} + * Redirect to shibboleth authentication url */ - public authMethod: AuthMethod; - - /** - * True if the authentication is loading. - * @type {boolean} - */ - public loading: Observable; - - /** - * The shibboleth authentication location url. - * @type {string} - */ - public location: string; - - /** - * Whether user is authenticated. - * @type {Observable} - */ - public isAuthenticated: Observable; - - /** - * @constructor - * @param {AuthMethod} injectedAuthMethodModel - * @param {boolean} isStandalonePage - * @param {NativeWindowRef} _window - * @param {RouteService} route - * @param {AuthService} authService - * @param {HardRedirectService} hardRedirectService - * @param {Store} store - */ - constructor( - @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, - @Inject('isStandalonePage') public isStandalonePage: boolean, - @Inject(NativeWindowService) protected _window: NativeWindowRef, - private route: RouteService, - private authService: AuthService, - private hardRedirectService: HardRedirectService, - private store: Store - ) { - this.authMethod = injectedAuthMethodModel; - } - - ngOnInit(): void { - // set isAuthenticated - this.isAuthenticated = this.store.pipe(select(isAuthenticated)); - - // set loading - this.loading = this.store.pipe(select(isAuthenticationLoading)); - - // set location - this.location = decodeURIComponent(this.injectedAuthMethodModel.location); - - } - redirectToShibboleth() { - - this.authService.getRedirectUrl().pipe(take(1)).subscribe((redirectRoute) => { - if (!this.isStandalonePage) { - redirectRoute = this.hardRedirectService.getCurrentRoute(); - } else if (isEmpty(redirectRoute)) { - redirectRoute = '/'; - } - const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString(); - - let shibbolethServerUrl = this.location; - const myRegexp = /\?redirectUrl=(.*)/g; - const match = myRegexp.exec(this.location); - const redirectUrlFromServer = (match && match[1]) ? match[1] : null; - - // Check whether the current page is different from the redirect url received from rest - if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { - // change the redirect url with the current page url - const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; - shibbolethServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); - } - - // redirect to shibboleth authentication url - this.hardRedirectService.redirect(shibbolethServerUrl); - }); - + this.redirectToExternalProvider(); } } diff --git a/src/app/shared/log-out/log-out.component.html b/src/app/shared/log-out/log-out.component.html index 9151cb0c2d..83e58b3841 100644 --- a/src/app/shared/log-out/log-out.component.html +++ b/src/app/shared/log-out/log-out.component.html @@ -2,5 +2,5 @@ - + diff --git a/src/app/shared/log-out/log-out.component.spec.ts b/src/app/shared/log-out/log-out.component.spec.ts index f15203ed8b..028738a019 100644 --- a/src/app/shared/log-out/log-out.component.spec.ts +++ b/src/app/shared/log-out/log-out.component.spec.ts @@ -12,6 +12,7 @@ import { Router } from '@angular/router'; import { AppState } from '../../app.reducer'; import { LogOutComponent } from './log-out.component'; import { RouterStub } from '../testing/router.stub'; +import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; describe('LogOutComponent', () => { @@ -46,7 +47,8 @@ describe('LogOutComponent', () => { TranslateModule.forRoot() ], declarations: [ - LogOutComponent + LogOutComponent, + BrowserOnlyMockPipe, ], providers: [ { provide: Router, useValue: routerStub }, diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts b/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts index e9552f9173..96444a5447 100644 --- a/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts +++ b/src/app/shared/menu/menu-item/link-menu-item.component.spec.ts @@ -4,7 +4,6 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { LinkMenuItemComponent } from './link-menu-item.component'; import { RouterLinkDirectiveStub } from '../../testing/router-link-directive.stub'; -import { environment } from '../../../../environments/environment'; import { QueryParamsDirectiveStub } from '../../testing/query-params-directive.stub'; import { RouterStub } from '../../testing/router.stub'; import { Router } from '@angular/router'; @@ -58,7 +57,7 @@ describe('LinkMenuItemComponent', () => { const routerLinkQuery = linkDes.map((de) => de.injector.get(RouterLinkDirectiveStub)); expect(routerLinkQuery.length).toBe(1); - expect(routerLinkQuery[0].routerLink).toBe(environment.ui.nameSpace + link); + expect(routerLinkQuery[0].routerLink).toBe(link); }); it('should have the right queryParams attribute', () => { diff --git a/src/app/shared/menu/menu-item/link-menu-item.component.ts b/src/app/shared/menu/menu-item/link-menu-item.component.ts index 09f08c4531..c9a60f0c28 100644 --- a/src/app/shared/menu/menu-item/link-menu-item.component.ts +++ b/src/app/shared/menu/menu-item/link-menu-item.component.ts @@ -2,7 +2,6 @@ import { Component, Inject, Input, OnInit } from '@angular/core'; import { LinkMenuItemModel } from './models/link.model'; import { rendersMenuItemForType } from '../menu-item.decorator'; import { isNotEmpty } from '../../empty.util'; -import { environment } from '../../../../environments/environment'; import { MenuItemType } from '../menu-item-type.model'; import { Router } from '@angular/router'; @@ -30,7 +29,7 @@ export class LinkMenuItemComponent implements OnInit { getRouterLink() { if (this.hasLink) { - return environment.ui.nameSpace + this.item.link; + return this.item.link; } return undefined; } diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts index ef8c0b1fad..f44ddea649 100644 --- a/src/app/shared/menu/menu.service.ts +++ b/src/app/shared/menu/menu.service.ts @@ -39,7 +39,7 @@ const menuByIDSelector = (menuID: MenuID): MemoizedSelector return keySelector(menuID, menusStateSelector); }; -const menuSectionStateSelector = (state: MenuState) => state.sections; +const menuSectionStateSelector = (state: MenuState) => hasValue(state) ? state.sections : {}; const menuSectionByIDSelector = (id: string): MemoizedSelector => { return menuKeySelector(id, menuSectionStateSelector); @@ -166,7 +166,7 @@ export class MenuService { */ isMenuCollapsed(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( - map((state: MenuState) => state.collapsed) + map((state: MenuState) => hasValue(state) ? state.collapsed : undefined) ); } @@ -177,7 +177,7 @@ export class MenuService { */ isMenuPreviewCollapsed(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( - map((state: MenuState) => state.previewCollapsed) + map((state: MenuState) => hasValue(state) ? state.previewCollapsed : undefined) ); } @@ -188,7 +188,7 @@ export class MenuService { */ isMenuVisible(menuID: MenuID): Observable { return this.getMenu(menuID).pipe( - map((state: MenuState) => state.visible) + map((state: MenuState) => hasValue(state) ? state.visible : undefined) ); } diff --git a/src/app/shared/mocks/find-id-config-data.service.mock.ts b/src/app/shared/mocks/find-id-config-data.service.mock.ts new file mode 100644 index 0000000000..c94fa6c0d6 --- /dev/null +++ b/src/app/shared/mocks/find-id-config-data.service.mock.ts @@ -0,0 +1,14 @@ +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; + +export function getMockFindByIdDataService(propertyKey: string, ...values: string[]) { + return jasmine.createSpyObj('findByIdDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$({ + ... new ConfigurationProperty(), + name: propertyKey, + values: values, + }) + }); +} + + diff --git a/src/app/shared/mocks/form-builder-service.mock.ts b/src/app/shared/mocks/form-builder-service.mock.ts index e37df20e13..6344ac6a6f 100644 --- a/src/app/shared/mocks/form-builder-service.mock.ts +++ b/src/app/shared/mocks/form-builder-service.mock.ts @@ -1,7 +1,9 @@ import { FormBuilderService } from '../form/builder/form-builder.service'; import { FormControl, FormGroup } from '@angular/forms'; +import {DsDynamicInputModel} from '../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; export function getMockFormBuilderService(): FormBuilderService { + return jasmine.createSpyObj('FormBuilderService', { modelFromConfiguration: [], createFormGroup: new FormGroup({}), @@ -17,8 +19,26 @@ export function getMockFormBuilderService(): FormBuilderService { isQualdropGroup: false, isModelInCustomGroup: true, isRelationGroup: true, - hasArrayGroupValue: true - + hasArrayGroupValue: true, + getTypeBindModel: new DsDynamicInputModel({ + name: 'dc.type', + id: 'dc_type', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'boundType', + display: 'Bound Type', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: ['dc.type'], + hasSelectableMetadata: false, + typeBindRelations: [ + {match: 'VISIBLE', operator: 'OR', when: [{id: 'dc.type', value: 'boundType'}]} + ] + } + ) }); } diff --git a/src/app/shared/mocks/form-models.mock.ts b/src/app/shared/mocks/form-models.mock.ts index c43138fa25..3529f9e81b 100644 --- a/src/app/shared/mocks/form-models.mock.ts +++ b/src/app/shared/mocks/form-models.mock.ts @@ -89,7 +89,8 @@ const rowArrayQualdropConfig = { submissionId: '1234', metadataKey: 'dc.some.key', metadataFields: ['dc.some.key'], - hasSelectableMetadata: false + hasSelectableMetadata: false, + showButtons: true } as DynamicRowArrayModelConfig; export const MockRowArrayQualdropModel: DynamicRowArrayModel = new DynamicRowArrayModel(rowArrayQualdropConfig); @@ -305,3 +306,57 @@ export const mockFileFormEditRowGroupModel = new DynamicRowGroupModel({ id: 'mockRowGroupModel', group: [mockFileFormEditInputModel] }); + +// Mock configuration and model for an input with type binding +export const inputWithTypeBindConfig = { + name: 'testWithTypeBind', + id: 'testWithTypeBind', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'testWithTypeBind', + display: 'testWithTypeBind', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + getTypeBindModel: new DsDynamicInputModel({ + name: 'testWithTypeBind', + id: 'testWithTypeBind', + readOnly: false, + disabled: false, + repeatable: false, + value: { + value: 'testWithTypeBind', + display: 'testWithTypeBind', + authority: 'bound-auth-key' + }, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + typeBindRelations: [ + {match: 'VISIBLE', operator: 'OR', when: [{'id': 'dc.type', 'value': 'boundType'}]} + ] + } + ) +}; + +export const mockInputWithTypeBindModel = new DsDynamicInputModel(inputWithAuthorityValueConfig); + +export const dcTypeInputConfig = { + name: 'dc.type', + id: 'dc_type', + readOnly: false, + disabled: false, + repeatable: false, + submissionId: '1234', + metadataFields: [], + hasSelectableMetadata: false, + value: { + value: 'boundType' + } +}; + +export const mockDcTypeInputModel = new DsDynamicInputModel(dcTypeInputConfig); diff --git a/src/app/shared/mocks/health-endpoint.mocks.ts b/src/app/shared/mocks/health-endpoint.mocks.ts new file mode 100644 index 0000000000..a9246d91a1 --- /dev/null +++ b/src/app/shared/mocks/health-endpoint.mocks.ts @@ -0,0 +1,160 @@ +import { + HealthComponent, + HealthInfoComponent, + HealthInfoResponse, + HealthResponse, + HealthStatus +} from '../../health-page/models/health-component.model'; + +export const HealthResponseObj: HealthResponse = { + 'status': HealthStatus.UP_WITH_ISSUES, + 'components': { + 'db': { + 'status': HealthStatus.UP, + 'components': { + 'dataSource': { + 'status': HealthStatus.UP, + 'details': { + 'database': 'PostgreSQL', + 'result': 1, + 'validationQuery': 'SELECT 1' + } + }, + 'dspaceDataSource': { + 'status': HealthStatus.UP, + 'details': { + 'database': 'PostgreSQL', + 'result': 1, + 'validationQuery': 'SELECT 1' + } + } + } + }, + 'geoIp': { + 'status': HealthStatus.UP_WITH_ISSUES, + 'details': { + 'reason': 'The GeoLite Database file is missing (/var/lib/GeoIP/GeoLite2-City.mmdb)! Solr Statistics cannot generate location based reports! Please see the DSpace installation instructions for instructions to install this file.' + } + }, + 'solrOaiCore': { + 'status': HealthStatus.UP, + 'details': { + 'status': 0, + 'detectedPathType': 'particular core' + } + }, + 'solrSearchCore': { + 'status': HealthStatus.UP, + 'details': { + 'status': 0, + 'detectedPathType': 'particular core' + } + }, + 'solrStatisticsCore': { + 'status': HealthStatus.UP, + 'details': { + 'status': 0, + 'detectedPathType': 'particular core' + } + } + } +}; + +export const HealthComponentOne: HealthComponent = { + 'status': HealthStatus.UP, + 'components': { + 'dataSource': { + 'status': HealthStatus.UP, + 'details': { + 'database': 'PostgreSQL', + 'result': 1, + 'validationQuery': 'SELECT 1' + } + }, + 'dspaceDataSource': { + 'status': HealthStatus.UP, + 'details': { + 'database': 'PostgreSQL', + 'result': 1, + 'validationQuery': 'SELECT 1' + } + } + } +}; + +export const HealthComponentTwo: HealthComponent = { + 'status': HealthStatus.UP_WITH_ISSUES, + 'details': { + 'reason': 'The GeoLite Database file is missing (/var/lib/GeoIP/GeoLite2-City.mmdb)! Solr Statistics cannot generate location based reports! Please see the DSpace installation instructions for instructions to install this file.' + } +}; + +export const HealthInfoResponseObj: HealthInfoResponse = { + 'app': { + 'name': 'DSpace at My University', + 'dir': '/home/giuseppe/development/java/install/dspace7-review', + 'url': 'http://localhost:8080/server', + 'db': 'jdbc:postgresql://localhost:5432/dspace7', + 'solr': { + 'server': 'http://localhost:8983/solr', + 'prefix': '' + }, + 'mail': { + 'server': 'smtp.example.com', + 'from-address': 'dspace-noreply@myu.edu', + 'feedback-recipient': 'dspace-help@myu.edu', + 'mail-admin': 'dspace-help@myu.edu', + 'mail-helpdesk': 'dspace-help@myu.edu', + 'alert-recipient': 'dspace-help@myu.edu' + }, + 'cors': { + 'allowed-origins': 'http://localhost:4000' + }, + 'ui': { + 'url': 'http://localhost:4000' + } + }, + 'java': { + 'vendor': 'Private Build', + 'version': '11.0.15', + 'runtime': { + 'name': 'OpenJDK Runtime Environment', + 'version': '11.0.15+10-Ubuntu-0ubuntu0.20.04.1' + }, + 'jvm': { + 'name': 'OpenJDK 64-Bit Server VM', + 'vendor': 'Private Build', + 'version': '11.0.15+10-Ubuntu-0ubuntu0.20.04.1' + } + }, + 'version': '7.3-SNAPSHOT' +}; + +export const HealthInfoComponentOne: HealthInfoComponent = { + 'name': 'DSpace at My University', + 'dir': '/home/giuseppe/development/java/install/dspace7-review', + 'url': 'http://localhost:8080/server', + 'db': 'jdbc:postgresql://localhost:5432/dspace7', + 'solr': { + 'server': 'http://localhost:8983/solr', + 'prefix': '' + }, + 'mail': { + 'server': 'smtp.example.com', + 'from-address': 'dspace-noreply@myu.edu', + 'feedback-recipient': 'dspace-help@myu.edu', + 'mail-admin': 'dspace-help@myu.edu', + 'mail-helpdesk': 'dspace-help@myu.edu', + 'alert-recipient': 'dspace-help@myu.edu' + }, + 'cors': { + 'allowed-origins': 'http://localhost:4000' + }, + 'ui': { + 'url': 'http://localhost:4000' + } +}; + +export const HealthInfoComponentTwo = { + 'version': '7.3-SNAPSHOT' +}; diff --git a/src/app/shared/mocks/notifications.mock.ts b/src/app/shared/mocks/notifications.mock.ts new file mode 100644 index 0000000000..ef6d99e9ea --- /dev/null +++ b/src/app/shared/mocks/notifications.mock.ts @@ -0,0 +1,1853 @@ +import { of as observableOf } from 'rxjs'; +import { ResourceType } from '../../core/shared/resource-type'; +import { QualityAssuranceTopicObject } from '../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceEventObject } from '../../core/suggestion-notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceTopicRestService } from '../../core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service'; +import { QualityAssuranceEventRestService } from '../../core/suggestion-notifications/qa/events/quality-assurance-event-rest.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { SearchResult } from '../search/models/search-result.model'; +import { QualityAssuranceSourceObject } from '../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; + +// REST Mock --------------------------------------------------------------------- +// ------------------------------------------------------------------------------- + +// Items +// ------------------------------------------------------------------------------- + +const ItemMockPid1: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174001', + uuid: 'ITEM4567-e89b-12d3-a456-426614174001', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Index nominum et rerum' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid2: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174004', + uuid: 'ITEM4567-e89b-12d3-a456-426614174004', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'UNA NUOVA RILETTURA DELL\u0027 ARISTOTELE DI FRANZ BRENTANO ALLA LUCE DI ALCUNI INEDITI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid3: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174005', + uuid: 'ITEM4567-e89b-12d3-a456-426614174005', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Sustainable development' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid4: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174006', + uuid: 'ITEM4567-e89b-12d3-a456-426614174006', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Reply to Critics' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid5: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174007', + uuid: 'ITEM4567-e89b-12d3-a456-426614174007', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'PROGETTAZIONE, SINTESI E VALUTAZIONE DELL\u0027ATTIVITA\u0027 ANTIMICOBATTERICA ED ANTIFUNGINA DI NUOVI DERIVATI ETEROCICLICI' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid6: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174008', + uuid: 'ITEM4567-e89b-12d3-a456-426614174008', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Donald Davidson' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +const ItemMockPid7: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174009', + uuid: 'ITEM4567-e89b-12d3-a456-426614174009', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Missing abstract article' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid8: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174002', + uuid: 'ITEM4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Egypt, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid9: Item = Object.assign( + new Item(), + { + handle: '10077/21486', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'ITEM4567-e89b-12d3-a456-426614174003', + uuid: 'ITEM4567-e89b-12d3-a456-426614174003', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Morocco, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const ItemMockPid10: Item = Object.assign( + new Item(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +export const NotificationsMockDspaceObject: SearchResult = Object.assign( + new SearchResult(), + { + handle: '10713/29832', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: true, + isWithdrawn: false, + _links:{ + self: { + href: 'https://rest.api/rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, + id: 'P23e4567-e89b-12d3-a456-426614174002', + uuid: 'P23e4567-e89b-12d3-a456-426614174002', + type: 'item', + metadata: { + 'dc.creator': [ + { + language: 'en_US', + value: 'Doe, Jane' + } + ], + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26' + } + ], + 'dc.identifier.issn': [ + { + language: 'en_US', + value: '123456789' + } + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/6' + } + ], + 'dc.description.abstract': [ + { + language: 'en_US', + value: 'This is really just a sample abstract. If it was a real abstract it would contain useful information about this test document. Sorry though, nothing useful in this paragraph. You probably shouldn\'t have even bothered to read it!' + } + ], + 'dc.description.provenance': [ + { + language: 'en', + value: 'Made available in DSpace on 2012-06-26T19:58:25Z (GMT). No. of bitstreams: 2\r\ntest_ppt.ppt: 12707328 bytes, checksum: a353fc7d29b3c558c986f7463a41efd3 (MD5)\r\ntest_ppt.pptx: 12468572 bytes, checksum: 599305edb4ebee329667f2c35b14d1d6 (MD5)' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T09:17:34Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2013-06-13T11:04:16Z (GMT).' + }, + { + language: 'en', + value: 'Restored into DSpace on 2017-04-24T19:44:08Z (GMT).' + } + ], + 'dc.language': [ + { + language: 'en_US', + value: 'en' + } + ], + 'dc.rights': [ + { + language: 'en_US', + value: '© Jane Doe' + } + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'keyword1' + }, + { + language: 'en_US', + value: 'keyword2' + }, + { + language: 'en_US', + value: 'keyword3' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + } + ], + 'dc.type': [ + { + language: 'en_US', + value: 'text' + } + ] + } + } +); + +// Sources +// ------------------------------------------------------------------------------- + +export const qualityAssuranceSourceObjectMorePid: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MORE!PID', + lastEvent: '2020/10/09 10:11 UTC', + totalEvents: 33, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MORE!PID' + } + } +}; + +export const qualityAssuranceSourceObjectMoreAbstract: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MORE!ABSTRACT', + lastEvent: '2020/09/08 21:14 UTC', + totalEvents: 5, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MORE!ABSTRACT' + } + } +}; + +export const qualityAssuranceSourceObjectMissingPid: QualityAssuranceSourceObject = { + type: new ResourceType('qasource'), + id: 'ENRICH!MISSING!PID', + lastEvent: '2020/10/01 07:36 UTC', + totalEvents: 4, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qasources/ENRICH!MISSING!PID' + } + } +}; + +// Topics +// ------------------------------------------------------------------------------- + +export const qualityAssuranceTopicObjectMorePid: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MORE!PID', + name: 'ENRICH/MORE/PID', + lastEvent: '2020/10/09 10:11 UTC', + totalEvents: 33, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MORE!PID' + } + } +}; + +export const qualityAssuranceTopicObjectMoreAbstract: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MORE!ABSTRACT', + name: 'ENRICH/MORE/ABSTRACT', + lastEvent: '2020/09/08 21:14 UTC', + totalEvents: 5, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MORE!ABSTRACT' + } + } +}; + +export const qualityAssuranceTopicObjectMissingPid: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!PID', + name: 'ENRICH/MISSING/PID', + lastEvent: '2020/10/01 07:36 UTC', + totalEvents: 4, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!PID' + } + } +}; + +export const qualityAssuranceTopicObjectMissingAbstract: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!ABSTRACT', + name: 'ENRICH/MISSING/ABSTRACT', + lastEvent: '2020/10/08 16:14 UTC', + totalEvents: 71, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!ABSTRACT' + } + } +}; + +export const qualityAssuranceTopicObjectMissingAcm: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!SUBJECT!ACM', + name: 'ENRICH/MISSING/SUBJECT/ACM', + lastEvent: '2020/09/21 17:51 UTC', + totalEvents: 18, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!SUBJECT!ACM' + } + } +}; + +export const qualityAssuranceTopicObjectMissingProject: QualityAssuranceTopicObject = { + type: new ResourceType('qatopic'), + id: 'ENRICH!MISSING!PROJECT', + name: 'ENRICH/MISSING/PROJECT', + lastEvent: '2020/09/17 10:28 UTC', + totalEvents: 6, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qatopics/ENRICH!MISSING!PROJECT' + } + } +}; + +// Events +// ------------------------------------------------------------------------------- + +export const qualityAssuranceEventObjectMissingPid: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174001', + uuid: '123e4567-e89b-12d3-a456-426614174001', + type: new ResourceType('qaevent'), + originalId: 'oai:www.openstarts.units.it:10077/21486', + title: 'Index nominum et rerum', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.18848/1447-9494/cgp/v15i09/45934', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001', + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174001/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid1)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid2: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174004', + uuid: '123e4567-e89b-12d3-a456-426614174004', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21486', + title: 'UNA NUOVA RILETTURA DELL\u0027 ARISTOTELE DI FRANZ BRENTANO ALLA LUCE DI ALCUNI INEDITI', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'urn', + value: 'http://thesis2.sba.units.it/store/handle/item/12238', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174004/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid2)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid3: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174005', + uuid: '123e4567-e89b-12d3-a456-426614174005', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/554', + title: 'Sustainable development', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.4324/9780203408889', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174005/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid3)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid4: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174006', + uuid: '123e4567-e89b-12d3-a456-426614174006', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/10787', + title: 'Reply to Critics', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.1080/13698230.2018.1430104', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174006/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid4)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid5: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174007', + uuid: '123e4567-e89b-12d3-a456-426614174007', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/11339', + title: 'PROGETTAZIONE, SINTESI E VALUTAZIONE DELL\u0027ATTIVITA\u0027 ANTIMICOBATTERICA ED ANTIFUNGINA DI NUOVI DERIVATI ETEROCICLICI', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'urn', + value: 'http://thesis2.sba.units.it/store/handle/item/12477', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174007/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid5)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingPid6: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174008', + uuid: '123e4567-e89b-12d3-a456-426614174008', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/29860', + title: 'Donald Davidson', + trust: 0.375, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: 'doi', + value: '10.1111/j.1475-4975.2004.00098.x', + abstract: null, + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174008/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid6)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingAbstract: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174009', + uuid: '123e4567-e89b-12d3-a456-426614174009', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21110', + title: 'Missing abstract article', + trust: 0.751, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + abstract: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque vestibulum tellus sed lacinia. Aenean vitae sapien a quam congue ultrices. Sed vehicula sollicitudin ligula, vitae lacinia velit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla scelerisque vestibulum tellus sed lacinia. Aenean vitae sapien a quam congue ultrices. Sed vehicula sollicitudin ligula, vitae lacinia velit.', + openaireId: null, + acronym: null, + code: null, + funder: null, + fundingProgram: null, + jurisdiction: null, + title: null + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174009/related' + } + }, + target: observableOf(createSuccessfulRemoteDataObject(ItemMockPid7)), + related: observableOf(createSuccessfulRemoteDataObject(ItemMockPid10)) +}; + +export const qualityAssuranceEventObjectMissingProjectFound: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174002', + uuid: '123e4567-e89b-12d3-a456-426614174002', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21838', + title: 'Egypt, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + abstract: null, + openaireId: null, + acronym: 'PAThs', + code: '687567', + funder: 'EC', + fundingProgram: 'H2020', + jurisdiction: 'EU', + title: 'Tracking Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174002/related' + } + }, + target: createSuccessfulRemoteDataObject$(ItemMockPid8), + related: createSuccessfulRemoteDataObject$(ItemMockPid10) +}; + +export const qualityAssuranceEventObjectMissingProjectNotFound: QualityAssuranceEventObject = { + id: '123e4567-e89b-12d3-a456-426614174003', + uuid: '123e4567-e89b-12d3-a456-426614174003', + type: new ResourceType('qualityAssuranceEvent'), + originalId: 'oai:www.openstarts.units.it:10077/21838', + title: 'Morocco, crossroad of translations and literary interweavings (3rd-6th centuries). A reconsideration of earlier Coptic literature', + trust: 1.0, + eventDate: '2020/10/09 10:11 UTC', + status: 'PENDING', + message: { + type: null, + value: null, + abstract: null, + openaireId: null, + acronym: 'PAThs', + code: '687567B', + funder: 'EC', + fundingProgram: 'H2021', + jurisdiction: 'EU', + title: 'Tracking Unknown Papyrus and Parchment Paths: An Archaeological Atlas of Coptic Literature.\nLiterary Texts in their Geographical Context: Production, Copying, Usage, Dissemination and Storage' + }, + _links: { + self: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003' + }, + target: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003/target' + }, + related: { + href: 'https://rest.api/rest/api/integration/qaevents/123e4567-e89b-12d3-a456-426614174003/related' + } + }, + target: createSuccessfulRemoteDataObject$(ItemMockPid9), + related: createNoContentRemoteDataObject$() +}; + +// Classes +// ------------------------------------------------------------------------------- + +/** + * Mock for [[SuggestionNotificationsStateService]] + */ +export function getMockNotificationsStateService(): any { + return jasmine.createSpyObj('NotificationsStateService', { + getQualityAssuranceTopics: jasmine.createSpy('getQualityAssuranceTopics'), + isQualityAssuranceTopicsLoading: jasmine.createSpy('isQualityAssuranceTopicsLoading'), + isQualityAssuranceTopicsLoaded: jasmine.createSpy('isQualityAssuranceTopicsLoaded'), + isQualityAssuranceTopicsProcessing: jasmine.createSpy('isQualityAssuranceTopicsProcessing'), + getQualityAssuranceTopicsTotalPages: jasmine.createSpy('getQualityAssuranceTopicsTotalPages'), + getQualityAssuranceTopicsCurrentPage: jasmine.createSpy('getQualityAssuranceTopicsCurrentPage'), + getQualityAssuranceTopicsTotals: jasmine.createSpy('getQualityAssuranceTopicsTotals'), + dispatchRetrieveQualityAssuranceTopics: jasmine.createSpy('dispatchRetrieveQualityAssuranceTopics'), + getQualityAssuranceSource: jasmine.createSpy('getQualityAssuranceSource'), + isQualityAssuranceSourceLoading: jasmine.createSpy('isQualityAssuranceSourceLoading'), + isQualityAssuranceSourceLoaded: jasmine.createSpy('isQualityAssuranceSourceLoaded'), + isQualityAssuranceSourceProcessing: jasmine.createSpy('isQualityAssuranceSourceProcessing'), + getQualityAssuranceSourceTotalPages: jasmine.createSpy('getQualityAssuranceSourceTotalPages'), + getQualityAssuranceSourceCurrentPage: jasmine.createSpy('getQualityAssuranceSourceCurrentPage'), + getQualityAssuranceSourceTotals: jasmine.createSpy('getQualityAssuranceSourceTotals'), + dispatchRetrieveQualityAssuranceSource: jasmine.createSpy('dispatchRetrieveQualityAssuranceSource'), + dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction') + }); +} + +/** + * Mock for [[QualityAssuranceSourceRestService]] + */ + export function getMockQualityAssuranceSourceRestService(): QualityAssuranceTopicRestService { + return jasmine.createSpyObj('QualityAssuranceSourceRestService', { + getSources: jasmine.createSpy('getSources'), + getSource: jasmine.createSpy('getSource'), + }); +} + +/** + * Mock for [[QualityAssuranceTopicRestService]] + */ +export function getMockQualityAssuranceTopicRestService(): QualityAssuranceTopicRestService { + return jasmine.createSpyObj('QualityAssuranceTopicRestService', { + getTopics: jasmine.createSpy('getTopics'), + getTopic: jasmine.createSpy('getTopic'), + }); +} + +/** + * Mock for [[QualityAssuranceEventRestService]] + */ +export function getMockQualityAssuranceEventRestService(): QualityAssuranceEventRestService { + return jasmine.createSpyObj('QualityAssuranceEventRestService', { + getEventsByTopic: jasmine.createSpy('getEventsByTopic'), + getEvent: jasmine.createSpy('getEvent'), + patchEvent: jasmine.createSpy('patchEvent'), + boundProject: jasmine.createSpy('boundProject'), + removeProject: jasmine.createSpy('removeProject'), + clearFindByTopicRequests: jasmine.createSpy('.clearFindByTopicRequests') + }); +} + +/** + * Mock for [[QualityAssuranceEventRestService]] + */ +export function getMockSuggestionsService(): any { + return jasmine.createSpyObj('SuggestionsService', { + getTargets: jasmine.createSpy('getTargets'), + getSuggestions: jasmine.createSpy('getSuggestions'), + clearSuggestionRequests: jasmine.createSpy('clearSuggestionRequests'), + deleteReviewedSuggestion: jasmine.createSpy('deleteReviewedSuggestion'), + retrieveCurrentUserSuggestions: jasmine.createSpy('retrieveCurrentUserSuggestions'), + getTargetUuid: jasmine.createSpy('getTargetUuid'), + }); +} diff --git a/src/app/shared/mocks/openaire.mock.ts b/src/app/shared/mocks/openaire.mock.ts index d6c50510cd..fd1a7c41f1 100644 --- a/src/app/shared/mocks/openaire.mock.ts +++ b/src/app/shared/mocks/openaire.mock.ts @@ -1,7 +1,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Item } from '../../core/shared/item.model'; import { SearchResult } from '../search/models/search-result.model'; -import { SuggestionsService } from '../../openaire/reciter-suggestions/suggestions.service'; +import { SuggestionsService } from '../../suggestion-notifications/reciter-suggestions/suggestions.service'; // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- @@ -1322,10 +1322,10 @@ export const OpenaireMockDspaceObject: SearchResult = Object.assig // ------------------------------------------------------------------------------- /** - * Mock for [[OpenaireStateService]] + * Mock for [[SuggestionNotificationsStateService]] */ -export function getMockOpenaireStateService(): any { - return jasmine.createSpyObj('OpenaireStateService', { +export function getMockSuggestionNotificationsStateService(): any { + return jasmine.createSpyObj('SuggestionNotificationsStateService', { getOpenaireBrokerTopics: jasmine.createSpy('getOpenaireBrokerTopics'), isOpenaireBrokerTopicsLoading: jasmine.createSpy('isOpenaireBrokerTopicsLoading'), isOpenaireBrokerTopicsLoaded: jasmine.createSpy('isOpenaireBrokerTopicsLoaded'), diff --git a/src/app/shared/mocks/reciter-suggestion-targets.mock.ts b/src/app/shared/mocks/reciter-suggestion-targets.mock.ts index 42e293e603..ae8cacb273 100644 --- a/src/app/shared/mocks/reciter-suggestion-targets.mock.ts +++ b/src/app/shared/mocks/reciter-suggestion-targets.mock.ts @@ -1,5 +1,5 @@ import { ResourceType } from '../../core/shared/resource-type'; -import { OpenaireSuggestionTarget } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- diff --git a/src/app/shared/mocks/reciter-suggestion.mock.ts b/src/app/shared/mocks/reciter-suggestion.mock.ts index 2ddb49868f..ae1732808e 100644 --- a/src/app/shared/mocks/reciter-suggestion.mock.ts +++ b/src/app/shared/mocks/reciter-suggestion.mock.ts @@ -2,8 +2,8 @@ // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- -import { OpenaireSuggestion } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; -import { SUGGESTION } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion-objects.resource-type'; +import { OpenaireSuggestion } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; +import { SUGGESTION } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-objects.resource-type'; export const mockSuggestionPublicationOne: OpenaireSuggestion = { id: '24694772', diff --git a/src/app/shared/mocks/request.service.mock.ts b/src/app/shared/mocks/request.service.mock.ts index f07aec587e..bce5b2d466 100644 --- a/src/app/shared/mocks/request.service.mock.ts +++ b/src/app/shared/mocks/request.service.mock.ts @@ -13,6 +13,7 @@ export function getMockRequestService(requestEntry$: Observable = isCachedOrPending: false, removeByHrefSubstring: observableOf(true), setStaleByHrefSubstring: observableOf(true), + setStaleByUUID: observableOf(true), hasByHref$: observableOf(false) }); } diff --git a/src/app/shared/mocks/router.mock.ts b/src/app/shared/mocks/router.mock.ts index eb260af6b4..98a63363b6 100644 --- a/src/app/shared/mocks/router.mock.ts +++ b/src/app/shared/mocks/router.mock.ts @@ -29,4 +29,8 @@ export class RouterMock { createUrlTree(commands, navExtras = {}) { return {}; } + + get url() { + return this.routerState.snapshot.url; + } } diff --git a/src/app/shared/mocks/section-sherpa-policies.service.mock.ts b/src/app/shared/mocks/section-sherpa-policies.service.mock.ts new file mode 100644 index 0000000000..9308325682 --- /dev/null +++ b/src/app/shared/mocks/section-sherpa-policies.service.mock.ts @@ -0,0 +1,101 @@ +import { + WorkspaceitemSectionSherpaPoliciesObject +} from '../../core/submission/models/workspaceitem-section-sherpa-policies.model'; + +export const SherpaDataResponse = { + 'id': 'sherpaPolicies', + 'retrievalTime': '2022-04-20T09:44:39.870+00:00', + 'sherpaResponse': + { + 'error': false, + 'message': null, + 'metadata': { + 'id': 23803, + 'uri': 'http://v2.sherpa.ac.uk/id/publication/23803', + 'dateCreated': '2012-11-20 14:51:52', + 'dateModified': '2020-03-06 11:25:54', + 'inDOAJ': false, + 'publiclyVisible': true + }, + 'journals': [{ + 'titles': ['The Lancet', 'Lancet'], + 'url': 'http://www.thelancet.com/journals/lancet/issue/current', + 'issns': ['0140-6736', '1474-547X'], + 'romeoPub': 'Elsevier: The Lancet', + 'zetoPub': 'Elsevier: The Lancet', + 'publisher': { + 'name': 'Elsevier', + 'relationshipType': null, + 'country': null, + 'uri': 'http://www.elsevier.com/', + 'identifier': null, + 'publicationCount': 0, + 'paidAccessDescription': 'Open access', + 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access' + }, + 'publishers': [{ + 'name': 'Elsevier', + 'relationshipType': null, + 'country': null, + 'uri': 'http://www.elsevier.com/', + 'identifier': null, + 'publicationCount': 0, + 'paidAccessDescription': 'Open access', + 'paidAccessUrl': 'https://www.elsevier.com/about/open-science/open-access' + }], + 'policies': [{ + 'id': 0, + 'openAccessPermitted': false, + 'uri': null, + 'internalMoniker': 'Lancet', + 'permittedVersions': [{ + 'articleVersion': 'submitted', + 'option': 1, + 'conditions': ['Upon publication publisher copyright and source must be acknowledged', 'Upon publication must link to publisher version'], + 'prerequisites': [], + 'locations': ['Author\'s Homepage', 'Preprint Repository'], + 'licenses': [], + 'embargo': null + }, { + 'articleVersion': 'accepted', + 'option': 1, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': [], + 'locations': ['Author\'s Homepage', 'Institutional Website'], + 'licenses': ['CC BY-NC-ND'], + 'embargo': null + }, { + 'articleVersion': 'accepted', + 'option': 2, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': ['If Required by Funder'], + 'locations': ['Non-Commercial Repository'], + 'licenses': ['CC BY-NC-ND'], + 'embargo': { amount: 6, units: 'Months' } + }, { + 'articleVersion': 'accepted', + 'option': 3, + 'conditions': ['Publisher copyright and source must be acknowledged', 'Must link to publisher version'], + 'prerequisites': [], + 'locations': ['Non-Commercial Repository'], + 'licenses': [], + 'embargo': null + }], + 'urls': { + 'http://download.thelancet.com/flatcontentassets/authors/lancet-information-for-authors.pdf': 'Guidelines for Authors', + 'http://www.thelancet.com/journals/lancet/article/PIIS0140-6736%2813%2960720-5/fulltext': 'The Lancet journals welcome a new open access policy', + 'http://www.thelancet.com/lancet-information-for-authors/after-publication': 'What happens after publication?', + 'http://www.thelancet.com/lancet/information-for-authors/disclosure-of-results': 'Disclosure of results before publication', + 'https://www.elsevier.com/__data/assets/pdf_file/0005/78476/external-embargo-list.pdf': 'Journal Embargo Period List', + 'https://www.elsevier.com/__data/assets/pdf_file/0011/78473/UK-Embargo-Periods.pdf': 'Journal Embargo List for UK Authors' + }, + 'openAccessProhibited': false, + 'publicationCount': 0, + 'preArchiving': 'can', + 'postArchiving': 'can', + 'pubArchiving': 'cannot' + }], + 'inDOAJ': false + }] + } +} as WorkspaceitemSectionSherpaPoliciesObject; diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html index 4ad6665cf8..47ae8cdb8e 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -1,22 +1,16 @@
- + - - - + - +
diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts index 5e6938853a..6f7970d71f 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts @@ -161,25 +161,22 @@ describe('ClaimedTaskActionsComponent', () => { }); })); - describe('when edit options is not available', () => { - it('should display a view button', waitForAsync(() => { - component.object = null; - component.initObjects(mockObject); - fixture.detectChanges(); + it('should display a view button', waitForAsync(() => { + component.object = null; + component.initObjects(mockObject); + fixture.detectChanges(); - fixture.whenStable().then(() => { - const debugElement = fixture.debugElement.query(By.css('.workflow-view')); - expect(debugElement).toBeTruthy(); - expect(debugElement.nativeElement.innerText.trim()).toBe('submission.workflow.generic.view'); - }); + fixture.whenStable().then(() => { + const debugElement = fixture.debugElement.query(By.css('.workflow-view')); + expect(debugElement).toBeTruthy(); + expect(debugElement.nativeElement.innerText.trim()).toBe('submission.workflow.generic.view'); + }); - })); - - it('getWorkflowItemViewRoute should return the combined uri to show a workspaceitem', waitForAsync(() => { - const href = component.getWorkflowItemViewRoute(workflowitem); - expect(href).toEqual('/workflowitems/333/view'); - })); - }); + })); + it('getWorkflowItemViewRoute should return the combined uri to show a workspaceitem', waitForAsync(() => { + const href = component.getWorkflowItemViewRoute(workflowitem); + expect(href).toEqual('/workflowitems/333/view'); + })); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts index 65ddd76bc4..386d2c2805 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -102,14 +102,6 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent - {{'submission.workflow.tasks.generic.processing' | translate}} - {{'submission.workflow.tasks.pool.claim' | translate}} + + diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts index bce1f1a467..5686030510 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { Router } from '@angular/router'; import { By } from '@angular/platform-browser'; @@ -133,6 +133,12 @@ describe('PoolTaskActionsComponent', () => { expect(btn).toBeDefined(); }); + it('should display view button', () => { + const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]')); + + expect(btn).toBeDefined(); + }); + it('should call claim task with href of getPoolTaskEndpointById', ((done) => { const poolTaskHref = 'poolTaskHref'; diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts index 92086ac817..45f51a5d4a 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts @@ -2,7 +2,7 @@ import { Component, Injector, Input, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; import { Observable } from 'rxjs'; -import {filter, map, switchMap, take} from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; @@ -19,6 +19,7 @@ import { Item } from '../../../core/shared/item.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { MyDSpaceReloadableActionsComponent } from '../mydspace-reloadable-actions'; import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; +import { getWorkflowItemViewRoute } from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths'; /** * This component represents mydspace actions related to PoolTask object. @@ -58,12 +59,12 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent * @param {RequestService} requestService */ constructor(protected injector: Injector, - protected router: Router, - protected notificationsService: NotificationsService, - protected claimedTaskService: ClaimedTaskDataService, - protected translate: TranslateService, - protected searchService: SearchService, - protected requestService: RequestService) { + protected router: Router, + protected notificationsService: NotificationsService, + protected claimedTaskService: ClaimedTaskDataService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { super(PoolTask.type, injector, router, notificationsService, translate, searchService, requestService); } @@ -91,7 +92,7 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent return this.objectDataService.getPoolTaskEndpointById(this.object.id) .pipe(switchMap((poolTaskHref) => { return this.claimedTaskService.claimTask(this.object.id, poolTaskHref); - })); + })); } reloadObjectExecution(): Observable | DSpaceObject> { @@ -107,12 +108,19 @@ export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) )) .subscribe((item: Item) => { - this.itemUuid = item.uuid; - }); + this.itemUuid = item.uuid; + }); } ngOnDestroy() { this.subs.forEach((sub) => sub.unsubscribe()); } + /** + * Get the workflowitem view route. + */ + getWorkflowItemViewRoute(workflowitem: WorkflowItem): string { + return getWorkflowItemViewRoute(workflowitem?.id); + } + } diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html index e69de29bb2..f6e5fecb50 100644 --- a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html +++ b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.html @@ -0,0 +1,5 @@ + diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts index 046eb2f018..79aece892e 100644 --- a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.spec.ts @@ -18,6 +18,7 @@ import { getMockRequestService } from '../../mocks/request.service.mock'; import { RequestService } from '../../../core/data/request.service'; import { getMockSearchService } from '../../mocks/search-service.mock'; import { SearchService } from '../../../core/shared/search/search.service'; +import { By } from '@angular/platform-browser'; let component: WorkflowitemActionsComponent; let fixture: ComponentFixture; @@ -105,4 +106,10 @@ describe('WorkflowitemActionsComponent', () => { expect(component.object).toEqual(mockObject); }); + it('should display view button', () => { + const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]')); + + expect(btn).toBeDefined(); + }); + }); diff --git a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts index 62a23ba66e..3587356642 100644 --- a/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts +++ b/src/app/shared/mydspace-actions/workflowitem/workflowitem-actions.component.ts @@ -9,6 +9,7 @@ import { WorkflowItemDataService } from '../../../core/submission/workflowitem-d import { NotificationsService } from '../../notifications/notifications.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { getWorkflowItemViewRoute } from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths'; /** * This component represents actions related to WorkflowItem object. @@ -44,6 +45,13 @@ export class WorkflowitemActionsComponent extends MyDSpaceActionsComponent - + + + + {{'submission.workflow.generic.edit' | translate}} - + diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts index f5f98a26ce..7299c28df2 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts @@ -132,6 +132,12 @@ describe('WorkspaceitemActionsComponent', () => { expect(btn).toBeDefined(); }); + it('should display view button', () => { + const btn = fixture.debugElement.query(By.css('button [data-test="view-btn"]')); + + expect(btn).toBeDefined(); + }); + describe('on discard confirmation', () => { beforeEach((done) => { mockDataService.delete.and.returnValue(observableOf(true)); diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts index a25ce335e3..a6d30728ac 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts @@ -14,6 +14,7 @@ import { SearchService } from '../../../core/shared/search/search.service'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { NoContent } from '../../../core/shared/NoContent.model'; +import { getWorkspaceItemViewRoute } from '../../../workspaceitems-edit-page/workspaceitems-edit-page-routing-paths'; /** * This component represents actions related to WorkspaceItem object. @@ -48,12 +49,12 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent
diff --git a/src/app/shared/notifications/notification/notification.component.scss b/src/app/shared/notifications/notification/notification.component.scss index 0321644585..06c46b0f5d 100644 --- a/src/app/shared/notifications/notification/notification.component.scss +++ b/src/app/shared/notifications/notification/notification.component.scss @@ -1,4 +1,4 @@ -.alert { +.notification { display: inline-block; min-width: var(--bs-modal-sm); text-align: left; diff --git a/src/app/shared/notifications/notification/notification.component.spec.ts b/src/app/shared/notifications/notification/notification.component.spec.ts index 8101f51329..f6de303726 100644 --- a/src/app/shared/notifications/notification/notification.component.spec.ts +++ b/src/app/shared/notifications/notification/notification.component.spec.ts @@ -98,7 +98,7 @@ describe('NotificationComponent', () => { it('should have html content', () => { fixture = TestBed.createComponent(NotificationComponent); comp = fixture.componentInstance; - const htmlContent = 'test'; + const htmlContent = 'test'; comp.notification = { id: '1', type: NotificationType.Info, diff --git a/src/app/shared/numeric.util.spec.ts b/src/app/shared/numeric.util.spec.ts new file mode 100644 index 0000000000..9602966299 --- /dev/null +++ b/src/app/shared/numeric.util.spec.ts @@ -0,0 +1,53 @@ +import { isNumeric } from './numeric.util'; + +describe('Numeric Utils', () => { + describe('isNumeric', () => { + it('should return true for Number values', () => { + expect(isNumeric(0)).toBeTrue(); + expect(isNumeric(123456)).toBeTrue(); + expect(isNumeric(-123456)).toBeTrue(); + expect(isNumeric(0.1234)).toBeTrue(); + expect(isNumeric(-0.1234)).toBeTrue(); + expect(isNumeric(1234e56)).toBeTrue(); + expect(isNumeric(-1234e-56)).toBeTrue(); + expect(isNumeric(0x123456)).toBeTrue(); + expect(isNumeric(-0x123456)).toBeTrue(); + }); + + it('should return true for numeric String values', () => { + expect(isNumeric('0')).toBeTrue(); + expect(isNumeric('123456')).toBeTrue(); + expect(isNumeric('-123456')).toBeTrue(); + expect(isNumeric('0.1234')).toBeTrue(); + expect(isNumeric('-0.1234')).toBeTrue(); + expect(isNumeric('1234e56')).toBeTrue(); + expect(isNumeric('-1234e-56')).toBeTrue(); + expect(isNumeric('0x123456')).toBeTrue(); + + // expect(isNumeric('-0x123456')).toBeTrue(); // not recognized as numeric, known issue + }); + + it('should return false for non-numeric String values', () => { + expect(isNumeric('just a regular string')).toBeFalse(); + expect(isNumeric('')).toBeFalse(); + expect(isNumeric(' ')).toBeFalse(); + expect(isNumeric('\n')).toBeFalse(); + expect(isNumeric('\t')).toBeFalse(); + expect(isNumeric('null')).toBeFalse(); + expect(isNumeric('undefined')).toBeFalse(); + }); + + it('should return false for any other kind of value', () => { + expect(isNumeric([1,2,3])).toBeFalse(); + expect(isNumeric({ a:1, b:2, c:3 })).toBeFalse(); + expect(isNumeric(() => { /* empty */ })).toBeFalse(); + expect(isNumeric(null)).toBeFalse(); + expect(isNumeric(undefined)).toBeFalse(); + expect(isNumeric(true)).toBeFalse(); + expect(isNumeric(false)).toBeFalse(); + expect(isNumeric(NaN)).toBeFalse(); + expect(isNumeric(Infinity)).toBeFalse(); + expect(isNumeric(-Infinity)).toBeFalse(); + }); + }); +}); diff --git a/src/app/shared/numeric.util.ts b/src/app/shared/numeric.util.ts new file mode 100644 index 0000000000..5a50ac8903 --- /dev/null +++ b/src/app/shared/numeric.util.ts @@ -0,0 +1,16 @@ +/** + * Whether a value is a Number or numeric string. + * + * Taken from RxJs 6.x (licensed under Apache 2.0) + * This function was removed from RxJs 7.x onwards. + * + * @param val: any value + * @returns whether this value is numeric + */ +export function isNumeric(val: any): val is number | string { + // parseFloat NaNs numeric-cast false positives (null|true|false|"") + // ...but misinterprets leading-number strings, particularly hex literals ("0x...") + // subtraction forces infinities to NaN + // adding 1 corrects loss of precision from parseFloat (#15100) + return !Array.isArray(val) && (val - parseFloat(val) + 1) >= 0; +} diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-search-result/item-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-search-result/item-search-result-detail-element.component.ts index 7e611ec3c8..27a94b0cf5 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-search-result/item-search-result-detail-element.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-search-result/item-search-result-detail-element.component.ts @@ -3,9 +3,12 @@ import { Component } from '@angular/core'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { Item } from '../../../../core/shared/item.model'; import { SearchResultDetailElementComponent } from '../search-result-detail-element.component'; -import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { + MyDspaceItemStatusType +} from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { Context } from '../../../../core/shared/context.model'; /** * This component renders item object for the search result in the detail view. @@ -16,7 +19,8 @@ import { ItemSearchResult } from '../../../object-collection/shared/item-search- templateUrl: './item-search-result-detail-element.component.html' }) -@listableObjectComponent(Item, ViewMode.DetailedListElement) +@listableObjectComponent(ItemSearchResult, ViewMode.DetailedListElement, Context.Workspace) +@listableObjectComponent(ItemSearchResult, ViewMode.DetailedListElement, Context.Workflow) export class ItemSearchResultDetailElementComponent extends SearchResultDetailElementComponent { /** diff --git a/src/app/shared/object-grid/object-grid.component.html b/src/app/shared/object-grid/object-grid.component.html index 07dbcec805..c988e76803 100644 --- a/src/app/shared/object-grid/object-grid.component.html +++ b/src/app/shared/object-grid/object-grid.component.html @@ -18,7 +18,7 @@ >
-
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html index 7fdb505d43..91fb85be40 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html @@ -1,45 +1,45 @@
- -
+
-
- -
- - -
-
- -
- - -
-
-
- - -

-
-

- - {{firstMetadataValue('dc.date.issued')}} - , - - - -

-

- - - -

- -
- +
+ +
+ + +
+
+ +
+ + +
+
+
+ + + + +

+
+ +

+ {{firstMetadataValue('dc.date.issued')}} + , + + +

+
+ +

+ +

+
+
+ +
diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.html b/src/app/shared/object-list/access-status-badge/access-status-badge.component.html new file mode 100644 index 0000000000..3877663419 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.html @@ -0,0 +1,5 @@ + +
+ {{ accessStatus | translate }} +
+
diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts b/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts new file mode 100644 index 0000000000..9101df2f4c --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.spec.ts @@ -0,0 +1,163 @@ +import { Item } from '../../../core/shared/item.model'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { TruncatePipe } from '../../utils/truncate.pipe'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { AccessStatusBadgeComponent } from './access-status-badge.component'; +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { By } from '@angular/platform-browser'; +import { AccessStatusObject } from './access-status.model'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { environment } from 'src/environments/environment'; + +describe('ItemAccessStatusBadgeComponent', () => { + let component: AccessStatusBadgeComponent; + let fixture: ComponentFixture; + + let unknownStatus: AccessStatusObject; + let metadataOnlyStatus: AccessStatusObject; + let openAccessStatus: AccessStatusObject; + let embargoStatus: AccessStatusObject; + let restrictedStatus: AccessStatusObject; + + let accessStatusDataService: AccessStatusDataService; + + let item: Item; + + function init() { + unknownStatus = Object.assign(new AccessStatusObject(), { + status: 'unknown' + }); + + metadataOnlyStatus = Object.assign(new AccessStatusObject(), { + status: 'metadata.only' + }); + + openAccessStatus = Object.assign(new AccessStatusObject(), { + status: 'open.access' + }); + + embargoStatus = Object.assign(new AccessStatusObject(), { + status: 'embargo' + }); + + restrictedStatus = Object.assign(new AccessStatusObject(), { + status: 'restricted' + }); + + accessStatusDataService = jasmine.createSpyObj('accessStatusDataService', { + findAccessStatusFor: createSuccessfulRemoteDataObject$(unknownStatus) + }); + + item = Object.assign(new Item(), { + uuid: 'item-uuid' + }); + } + + function initTestBed() { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [AccessStatusBadgeComponent, TruncatePipe], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + {provide: AccessStatusDataService, useValue: accessStatusDataService} + ] + }).compileComponents(); + } + + function initFixtureAndComponent() { + environment.item.showAccessStatuses = true; + fixture = TestBed.createComponent(AccessStatusBadgeComponent); + component = fixture.componentInstance; + component.item = item; + fixture.detectChanges(); + environment.item.showAccessStatuses = false; + } + + function lookForAccessStatusBadge(status: string) { + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge.nativeElement.textContent).toEqual(`access-status.${status.toLowerCase()}.listelement.badge`); + } + + describe('init', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should init the component', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('When the findAccessStatusFor method returns unknown', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the unknown badge', () => { + lookForAccessStatusBadge('unknown'); + }); + }); + + describe('When the findAccessStatusFor method returns metadata.only', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(metadataOnlyStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the metadata only badge', () => { + lookForAccessStatusBadge('metadata.only'); + }); + }); + + describe('When the findAccessStatusFor method returns open.access', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(openAccessStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the open access badge', () => { + lookForAccessStatusBadge('open.access'); + }); + }); + + describe('When the findAccessStatusFor method returns embargo', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(embargoStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the embargo badge', () => { + lookForAccessStatusBadge('embargo'); + }); + }); + + describe('When the findAccessStatusFor method returns restricted', () => { + beforeEach(waitForAsync(() => { + init(); + (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(restrictedStatus)); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(); + }); + it('should show the restricted badge', () => { + lookForAccessStatusBadge('restricted'); + }); + }); +}); diff --git a/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts b/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts new file mode 100644 index 0000000000..fbca3cb971 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status-badge.component.ts @@ -0,0 +1,57 @@ +import { Component, Input } from '@angular/core'; +import { catchError, map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { AccessStatusObject } from './access-status.model'; +import { hasValue } from '../../empty.util'; +import { environment } from 'src/environments/environment'; +import { Item } from 'src/app/core/shared/item.model'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; + +@Component({ + selector: 'ds-access-status-badge', + templateUrl: './access-status-badge.component.html' +}) +/** + * Component rendering the access status of an item as a badge + */ +export class AccessStatusBadgeComponent { + + @Input() item: Item; + accessStatus$: Observable; + + /** + * Whether to show the access status badge or not + */ + showAccessStatus: boolean; + + /** + * Initialize instance variables + * + * @param {AccessStatusDataService} accessStatusDataService + */ + constructor(private accessStatusDataService: AccessStatusDataService) { } + + ngOnInit(): void { + this.showAccessStatus = environment.item.showAccessStatuses; + if (!this.showAccessStatus || this.item == null) { + // Do not show the badge if the feature is inactive or if the item is null. + return; + } + if (this.item.accessStatus == null) { + // In case the access status has not been loaded, do it individually. + this.item.accessStatus = this.accessStatusDataService.findAccessStatusFor(this.item); + } + this.accessStatus$ = this.item.accessStatus.pipe( + map((accessStatusRD) => { + if (accessStatusRD.statusCode !== 401 && hasValue(accessStatusRD.payload)) { + return accessStatusRD.payload; + } else { + return []; + } + }), + map((accessStatus: AccessStatusObject) => hasValue(accessStatus.status) ? accessStatus.status : 'unknown'), + map((status: string) => `access-status.${status.toLowerCase()}.listelement.badge`), + catchError(() => observableOf('access-status.unknown.listelement.badge')) + ); + } +} diff --git a/src/app/shared/object-list/access-status-badge/access-status.model.ts b/src/app/shared/object-list/access-status-badge/access-status.model.ts new file mode 100644 index 0000000000..69b5e920d0 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status.model.ts @@ -0,0 +1,33 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from 'src/app/core/cache/builders/build-decorators'; +import { CacheableObject } from 'src/app/core/cache/cacheable-object.model'; +import { HALLink } from 'src/app/core/shared/hal-link.model'; +import { ResourceType } from 'src/app/core/shared/resource-type'; +import { excludeFromEquals } from 'src/app/core/utilities/equals.decorators'; +import { ACCESS_STATUS } from './access-status.resource-type'; + +@typedObject +export class AccessStatusObject implements CacheableObject { + static type = ACCESS_STATUS; + + /** + * The type for this AccessStatusObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The access status value + */ + @autoserialize + status: string; + + /** + * The {@link HALLink}s for this AccessStatusObject + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts b/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts new file mode 100644 index 0000000000..ead2afc0b1 --- /dev/null +++ b/src/app/shared/object-list/access-status-badge/access-status.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from 'src/app/core/shared/resource-type'; + +/** + * The resource type for Access Status + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ACCESS_STATUS = new ResourceType('accessStatus'); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index c518d39bd9..6d4c704b60 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -2,7 +2,10 @@ - +
+ + +

diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 840960d51f..34b2d979c1 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -35,5 +35,4 @@ export class ItemListPreviewComponent { * A boolean representing if to show submitter information */ @Input() showSubmitter = false; - } diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 492cb4cf07..927f2b9d2a 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -17,7 +17,7 @@ (next)="goNext()" >
+
diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index 6da813cbc7..1bfc1dfa59 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -122,6 +122,11 @@ export class PaginationComponent implements OnDestroy, OnInit { */ @Input() public hideGear = false; + /** + * Option for hiding the gear + */ + @Input() public hideSortOptions = false; + /** * Option for hiding the pager when there is less than 2 pages */ diff --git a/src/app/shared/remote-data.utils.ts b/src/app/shared/remote-data.utils.ts index 2a7dee6383..50b7d7f9f9 100644 --- a/src/app/shared/remote-data.utils.ts +++ b/src/app/shared/remote-data.utils.ts @@ -61,7 +61,7 @@ export function createFailedRemoteDataObject(errorMessage?: string, statusCod * @param timeCompleted the moment when the remoteData was completed */ export function createFailedRemoteDataObject$(errorMessage?: string, statusCode?: number, timeCompleted?: number): Observable> { - return observableOf(createFailedRemoteDataObject(errorMessage, statusCode, timeCompleted)); + return observableOf(createFailedRemoteDataObject(errorMessage, statusCode, timeCompleted)); } /** @@ -85,7 +85,7 @@ export function createPendingRemoteDataObject(lastVerified = FIXED_TIMESTAMP) * @param lastVerified the moment when the remoteData was last verified */ export function createPendingRemoteDataObject$(lastVerified?: number): Observable> { - return observableOf(createPendingRemoteDataObject(lastVerified)); + return observableOf(createPendingRemoteDataObject(lastVerified)); } /** diff --git a/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts index a515eef675..1c5a0c4a5f 100644 --- a/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts +++ b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of, combineLatest as observableCombineLatest, } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; @@ -88,16 +88,33 @@ export class ResourcePolicyEditComponent implements OnInit { type: RESOURCE_POLICY.value, _links: this.resourcePolicy._links }); - this.resourcePolicyService.update(updatedObject).pipe( + + const updateTargetSucceeded$ = event.updateTarget ? this.resourcePolicyService.updateTarget( + this.resourcePolicy.id, this.resourcePolicy._links.self.href, event.target.uuid, event.target.type + ).pipe( getFirstCompletedRemoteData(), - ).subscribe((responseRD: RemoteData) => { - this.processing$.next(false); - if (responseRD && responseRD.hasSucceeded) { - this.notificationsService.success(null, this.translate.get('resource-policies.edit.page.success.content')); - this.redirectToAuthorizationsPage(); - } else { - this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.failure.content')); + map((responseRD) => responseRD && responseRD.hasSucceeded) + ) : of(true); + + const updateResourcePolicySucceeded$ = this.resourcePolicyService.update(updatedObject).pipe( + getFirstCompletedRemoteData(), + map((responseRD) => responseRD && responseRD.hasSucceeded) + ); + + observableCombineLatest([updateTargetSucceeded$, updateResourcePolicySucceeded$]).subscribe( + ([updateTargetSucceeded, updateResourcePolicySucceeded]) => { + this.processing$.next(false); + if (updateTargetSucceeded && updateResourcePolicySucceeded) { + this.notificationsService.success(null, this.translate.get('resource-policies.edit.page.success.content')); + this.redirectToAuthorizationsPage(); + } else if (updateResourcePolicySucceeded) { // everything except target has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.target-failure.content')); + } else if (updateTargetSucceeded) { // only target has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.other-failure.content')); + } else { // nothing has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.failure.content')); + } } - }); + ); } } diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.html b/src/app/shared/resource-policies/form/resource-policy-form.component.html index 42475a955b..66c1fc400e 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.html +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.html @@ -7,25 +7,23 @@ [displayCancel]="false"> + + + + + + + diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts index 5cc6397118..e555522c79 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts @@ -242,6 +242,7 @@ describe('ResourcePolicyFormComponent test suite', () => { fixture = TestBed.createComponent(ResourcePolicyFormComponent); comp = fixture.componentInstance; compAsAny = fixture.componentInstance; + compAsAny.resourcePolicy = resourcePolicy; comp.isProcessing = observableOf(false); }); @@ -253,6 +254,8 @@ describe('ResourcePolicyFormComponent test suite', () => { }); it('should init form model properly', () => { + epersonService.findByHref.and.returnValue(observableOf(undefined)); + groupService.findByHref.and.returnValue(observableOf(undefined)); spyOn(compAsAny, 'isFormValid').and.returnValue(observableOf(false)); spyOn(compAsAny, 'initModelsValue').and.callThrough(); spyOn(compAsAny, 'buildResourcePolicyForm').and.callThrough(); @@ -261,12 +264,12 @@ describe('ResourcePolicyFormComponent test suite', () => { expect(compAsAny.buildResourcePolicyForm).toHaveBeenCalled(); expect(compAsAny.initModelsValue).toHaveBeenCalled(); expect(compAsAny.formModel.length).toBe(5); - expect(compAsAny.subs.length).toBe(0); + expect(compAsAny.subs.length).toBe(1); }); it('should can set grant', () => { - expect(comp.canSetGrant()).toBeTruthy(); + expect(comp.isBeingEdited()).toBeTruthy(); }); it('should not have a target name', () => { @@ -279,7 +282,7 @@ describe('ResourcePolicyFormComponent test suite', () => { expect(compAsAny.reset.emit).toHaveBeenCalled(); }); - it('should update resource policy grant object properly', () => { + it('should update resource policy grant object properly', () => { comp.updateObjectSelected(EPersonMock, true); expect(comp.resourcePolicyGrant).toEqual(EPersonMock); @@ -301,6 +304,7 @@ describe('ResourcePolicyFormComponent test suite', () => { comp = fixture.componentInstance; compAsAny = fixture.componentInstance; comp.resourcePolicy = resourcePolicy; + compAsAny.resourcePolicy = resourcePolicy; comp.isProcessing = observableOf(false); compAsAny.ePersonService.findByHref.and.returnValue( observableOf(createSuccessfulRemoteDataObject({})).pipe(delay(100)) @@ -343,8 +347,8 @@ describe('ResourcePolicyFormComponent test suite', () => { }); }); - it('should not can set grant', () => { - expect(comp.canSetGrant()).toBeFalsy(); + it('should be being edited', () => { + expect(comp.isBeingEdited()).toBeTrue(); }); it('should have a target name', () => { @@ -398,6 +402,7 @@ describe('ResourcePolicyFormComponent test suite', () => { type: 'group', uuid: GroupMock.id }; + eventPayload.updateTarget = false; scheduler = getTestScheduler(); scheduler.schedule(() => comp.onSubmit()); diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.ts index 7ae699340e..223e610908 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Observable, @@ -41,6 +41,7 @@ import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; +import { NgbModal, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'; export interface ResourcePolicyEvent { object: ResourcePolicy; @@ -48,6 +49,7 @@ export interface ResourcePolicyEvent { type: string, uuid: string }; + updateTarget: boolean; } @Component({ @@ -83,6 +85,8 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ @Output() submit: EventEmitter = new EventEmitter(); + @ViewChild('content') content: ElementRef; + /** * The form id * @type {string} @@ -125,6 +129,10 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ private subs: Subscription[] = []; + navActiveId: string; + + resourcePolicyTargetUpdated = false; + /** * Initialize instance variables * @@ -133,6 +141,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * @param {FormService} formService * @param {GroupDataService} groupService * @param {RequestService} requestService + * @param modalService */ constructor( private dsoNameService: DSONameService, @@ -140,6 +149,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { private formService: FormService, private groupService: GroupDataService, private requestService: RequestService, + private modalService: NgbModal, ) { } @@ -151,7 +161,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { this.formId = this.formService.getUniqueId('resource-policy-form'); this.formModel = this.buildResourcePolicyForm(); - if (!this.canSetGrant()) { + if (this.isBeingEdited()) { const epersonRD$ = this.ePersonService.findByHref(this.resourcePolicy._links.eperson.href, false).pipe( getFirstSucceededRemoteData() ); @@ -169,6 +179,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { filter(() => this.isActive), ).subscribe((dsoRD: RemoteData) => { this.resourcePolicyGrant = dsoRD.payload; + this.navActiveId = String(dsoRD.payload.type); this.resourcePolicyTargetName$.next(this.getResourcePolicyTargetName()); }) ); @@ -193,19 +204,12 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ private buildResourcePolicyForm(): DynamicFormControlModel[] { const formModel: DynamicFormControlModel[] = []; - // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented - const policyTypeConf = Object.assign({}, RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG, { - disabled: isNotEmpty(this.resourcePolicy) - }); - // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented - const actionConf = Object.assign({}, RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG, { - disabled: isNotEmpty(this.resourcePolicy) - }); + formModel.push( new DsDynamicInputModel(RESOURCE_POLICY_FORM_NAME_CONFIG), new DsDynamicTextAreaModel(RESOURCE_POLICY_FORM_DESCRIPTION_CONFIG), - new DynamicSelectModel(policyTypeConf), - new DynamicSelectModel(actionConf) + new DynamicSelectModel(RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG), + new DynamicSelectModel(RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG) ); const startDateModel = new DynamicDatePickerModel( @@ -255,8 +259,8 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * * @return true if is possible, false otherwise */ - canSetGrant(): boolean { - return isEmpty(this.resourcePolicy); + isBeingEdited(): boolean { + return !isEmpty(this.resourcePolicy); } /** @@ -272,8 +276,10 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * Update reference to the eperson or group that will be granted the permission */ updateObjectSelected(object: DSpaceObject, isEPerson: boolean): void { + this.resourcePolicyTargetUpdated = true; this.resourcePolicyGrant = object; this.resourcePolicyGrantType = isEPerson ? 'eperson' : 'group'; + this.resourcePolicyTargetName$.next(this.getResourcePolicyTargetName()); } /** @@ -297,6 +303,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { type: this.resourcePolicyGrantType, uuid: this.resourcePolicyGrant.id }; + eventPayload.updateTarget = this.resourcePolicyTargetUpdated; this.submit.emit(eventPayload); }); } @@ -329,4 +336,12 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { .filter((subscription) => hasValue(subscription)) .forEach((subscription) => subscription.unsubscribe()); } + + onNavChange(changeEvent: NgbNavChangeEvent) { + // if a policy is being edited it should not be possible to switch between group and eperson + if (this.isBeingEdited()) { + changeEvent.preventDefault(); + this.modalService.open(this.content); + } + } } diff --git a/src/app/shared/resource-policies/resource-policies.component.html b/src/app/shared/resource-policies/resource-policies.component.html index 0a1ccf7952..d1fd9266b1 100644 --- a/src/app/shared/resource-policies/resource-policies.component.html +++ b/src/app/shared/resource-policies/resource-policies.component.html @@ -4,9 +4,15 @@
- {{ 'resource-policies.table.headers.title.for.' + resourceType | translate }} {{resourceUUID}} + + {{ 'resource-policies.table.headers.title.for.' + resourceType | translate }} + {{resourceName}} + + ({{resourceUUID}}) + +
- -
- - +
diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 934f00b10c..4b5844f660 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -13,7 +13,10 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; +import { SearchServiceStub } from '../testing/search-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../testing/router.stub'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -21,23 +24,28 @@ describe('SearchFormComponent', () => { let de: DebugElement; let el: HTMLElement; + const router = new RouterStub(); + const searchService = new SearchServiceStub(); const paginationService = new PaginationServiceStub(); - - const searchConfigService = {paginationID: 'test-id'}; + const searchConfigService = { paginationID: 'test-id' }; + const dspaceObjectService = { + findById: () => createSuccessfulRemoteDataObject$(undefined), + }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [FormsModule, RouterTestingModule, TranslateModule.forRoot()], providers: [ - { - provide: SearchService, - useValue: {} - }, + { provide: Router, useValue: router }, + { provide: SearchService, useValue: searchService }, { provide: PaginationService, useValue: paginationService }, { provide: SearchConfigurationService, useValue: searchConfigService }, - { provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} } + { provide: DSpaceObjectDataService, useValue: dspaceObjectService }, ], - declarations: [SearchFormComponent] + declarations: [ + SearchFormComponent, + BrowserOnlyMockPipe, + ] }).compileComponents(); })); @@ -90,6 +98,81 @@ describe('SearchFormComponent', () => { expect(scopeSelect.textContent).toBe(testCommunity.name); })); + + describe('updateSearch', () => { + const query = 'THOR'; + const scope = 'MCU'; + let searchQuery = {}; + + it('should navigate to the search page even when no parameters are provided', () => { + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + + it('should navigate to the search page with parameters only query if only query is provided', () => { + searchQuery = { + query: query + }; + + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + + it('should navigate to the search page with parameters only query if only scope is provided', () => { + searchQuery = { + scope: scope + }; + + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + }); + + describe('when the scope variable is used', () => { + const query = 'THOR'; + const scope = 'MCU'; + let searchQuery = {}; + + beforeEach(() => { + spyOn(comp, 'updateSearch'); + }); + + it('should only search in the provided scope', () => { + searchQuery = { + query: query, + scope: scope + }; + + comp.scope = scope; + comp.onSubmit(searchQuery); + + expect(comp.updateSearch).toHaveBeenCalledWith(searchQuery); + }); + + it('should not create searchQuery with the scope if an empty scope is provided', () => { + searchQuery = { + query: query + }; + + comp.scope = ''; + comp.onSubmit(searchQuery); + + expect(comp.updateSearch).toHaveBeenCalledWith(searchQuery); + }); + }); + // it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => { // comp.query = 'Test String' // fixture.detectChanges(); @@ -112,7 +195,7 @@ describe('SearchFormComponent', () => { // // expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query }); // })); - }); +}); export const objects: DSpaceObject[] = [ Object.assign(new Community(), { diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index caf6a91046..7ea51e4c1e 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -98,6 +98,9 @@ export class SearchFormComponent implements OnInit { * @param data Values submitted using the form */ onSubmit(data: any) { + if (isNotEmpty(this.scope)) { + data = Object.assign(data, { scope: this.scope }); + } this.updateSearch(data); this.submitSearch.emit(data); } diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.html b/src/app/shared/search/search-export-csv/search-export-csv.component.html new file mode 100644 index 0000000000..7bf8704300 --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.scss b/src/app/shared/search/search-export-csv/search-export-csv.component.scss new file mode 100644 index 0000000000..4b0ab3c44a --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.scss @@ -0,0 +1,4 @@ +.export-button { + background: var(--ds-admin-sidebar-bg); + border-color: var(--ds-admin-sidebar-bg); +} \ No newline at end of file diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts b/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts new file mode 100644 index 0000000000..82c15feeac --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.spec.ts @@ -0,0 +1,182 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { SearchExportCsvComponent } from './search-export-csv.component'; +import { ScriptDataService } from '../../../core/data/processes/script-data.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { Script } from '../../../process-page/scripts/script.model'; +import { Process } from '../../../process-page/processes/process.model'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths'; +import { SearchFilter } from '../models/search-filter.model'; +import { PaginatedSearchOptions } from '../models/paginated-search-options.model'; + +describe('SearchExportCsvComponent', () => { + let component: SearchExportCsvComponent; + let fixture: ComponentFixture; + + let scriptDataService: ScriptDataService; + let authorizationDataService: AuthorizationDataService; + let notificationsService; + let router; + + const script = Object.assign(new Script(), {id: 'metadata-export-search', name: 'metadata-export-search'}); + const process = Object.assign(new Process(), {processId: 5, scriptName: 'metadata-export-search'}); + + const searchConfig = new PaginatedSearchOptions({ + configuration: 'test-configuration', + scope: 'test-scope', + query: 'test-query', + filters: [ + new SearchFilter('f.filter1', ['filter1value1,equals', 'filter1value2,equals']), + new SearchFilter('f.filter2', ['filter2value1,contains']), + new SearchFilter('f.filter3', ['[2000 TO 2001]'], 'equals') + ] + }); + + function initBeforeEachAsync() { + scriptDataService = jasmine.createSpyObj('scriptDataService', { + findById: createSuccessfulRemoteDataObject$(script), + invoke: createSuccessfulRemoteDataObject$(process) + }); + authorizationDataService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + + notificationsService = new NotificationsServiceStub(); + + router = jasmine.createSpyObj('authorizationService', ['navigateByUrl']); + TestBed.configureTestingModule({ + declarations: [SearchExportCsvComponent], + imports: [TranslateModule.forRoot(), NgbModule], + providers: [ + {provide: ScriptDataService, useValue: scriptDataService}, + {provide: AuthorizationDataService, useValue: authorizationDataService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: Router, useValue: router}, + ] + }).compileComponents(); + } + + function initBeforeEach() { + fixture = TestBed.createComponent(SearchExportCsvComponent); + component = fixture.componentInstance; + component.searchConfig = searchConfig; + fixture.detectChanges(); + } + + describe('init', () => { + describe('comp', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + describe('when the user is an admin and the metadata-export-search script is present ', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeDefined(); + }); + }); + describe('when the user is not an admin', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + describe('when the metadata-export-search script is not present', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + (scriptDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not found', 404)); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should should not add the button', () => { + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + expect(debugElement).toBeNull(); + }); + }); + }); + describe('export', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should call the invoke script method with the correct parameters', () => { + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-search', + [ + {name: '-q', value: searchConfig.query}, + {name: '-s', value: searchConfig.scope}, + {name: '-c', value: searchConfig.configuration}, + {name: '-f', value: 'filter1,equals=filter1value1'}, + {name: '-f', value: 'filter1,equals=filter1value2'}, + {name: '-f', value: 'filter2,contains=filter2value1'}, + {name: '-f', value: 'filter3,equals=[2000 TO 2001]'}, + ], []); + + component.searchConfig = null; + fixture.detectChanges(); + + component.export(); + expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-search', [], []); + + }); + it('should show a success message when the script was invoked successfully and redirect to the corresponding process page', () => { + component.export(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute(process.processId)); + }); + it('should show an error message when the script was not invoked successfully and stay on the current page', () => { + (scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500)); + + component.export(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + describe('clicking the button', () => { + beforeEach(waitForAsync(() => { + initBeforeEachAsync(); + })); + beforeEach(() => { + initBeforeEach(); + }); + it('should trigger the export function', () => { + spyOn(component, 'export'); + + const debugElement = fixture.debugElement.query(By.css('button.export-button')); + debugElement.triggerEventHandler('click', null); + + expect(component.export).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/shared/search/search-export-csv/search-export-csv.component.ts b/src/app/shared/search/search-export-csv/search-export-csv.component.ts new file mode 100644 index 0000000000..6ad105342f --- /dev/null +++ b/src/app/shared/search/search-export-csv/search-export-csv.component.ts @@ -0,0 +1,110 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { ScriptDataService } from '../../../core/data/processes/script-data.service'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { map } from 'rxjs/operators'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { hasValue, isNotEmpty } from '../../empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Process } from '../../../process-page/processes/process.model'; +import { getProcessDetailRoute } from '../../../process-page/process-page-routing.paths'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { PaginatedSearchOptions } from '../models/paginated-search-options.model'; + +@Component({ + selector: 'ds-search-export-csv', + styleUrls: ['./search-export-csv.component.scss'], + templateUrl: './search-export-csv.component.html', +}) +/** + * Display a button to export the current search results as csv + */ +export class SearchExportCsvComponent implements OnInit { + + /** + * The current configuration of the search + */ + @Input() searchConfig: PaginatedSearchOptions; + + /** + * Observable used to determine whether the button should be shown + */ + shouldShowButton$: Observable; + + /** + * The message key used for the tooltip of the button + */ + tooltipMsg = 'metadata-export-search.tooltip'; + + constructor(private scriptDataService: ScriptDataService, + private authorizationDataService: AuthorizationDataService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router + ) { + } + + ngOnInit(): void { + const scriptExists$ = this.scriptDataService.findById('metadata-export-search').pipe( + getFirstCompletedRemoteData(), + map((rd) => rd.isSuccess && hasValue(rd.payload)) + ); + + const isAuthorized$ = this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf); + + this.shouldShowButton$ = observableCombineLatest([scriptExists$, isAuthorized$]).pipe( + map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized) + ); + } + + /** + * Start the export of the items based on the current search configuration + */ + export() { + const parameters = []; + if (hasValue(this.searchConfig)) { + if (isNotEmpty(this.searchConfig.query)) { + parameters.push({name: '-q', value: this.searchConfig.query}); + } + if (isNotEmpty(this.searchConfig.scope)) { + parameters.push({name: '-s', value: this.searchConfig.scope}); + } + if (isNotEmpty(this.searchConfig.configuration)) { + parameters.push({name: '-c', value: this.searchConfig.configuration}); + } + if (isNotEmpty(this.searchConfig.filters)) { + this.searchConfig.filters.forEach((filter) => { + if (hasValue(filter.values)) { + filter.values.forEach((value) => { + let operator; + let filterValue; + if (hasValue(filter.operator)) { + operator = filter.operator; + filterValue = value; + } else { + operator = value.substring(value.lastIndexOf(',') + 1); + filterValue = value.substring(0, value.lastIndexOf(',')); + } + const valueToAdd = `${filter.key.substring(2)},${operator}=${filterValue}`; + parameters.push({name: '-f', value: valueToAdd}); + }); + } + }); + } + } + + this.scriptDataService.invoke('metadata-export-search', parameters, []).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('metadata-export-search.submit.success')); + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } else { + this.notificationsService.error(this.translateService.get('metadata-export-search.submit.error')); + } + }); + } +} diff --git a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html index 44aed494e3..fdf154bc04 100644 --- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -24,7 +24,7 @@ [name]="filterConfig.paramName" [(ngModel)]="filter" (submitSuggestion)="onSubmit($event)" - (clickSuggestion)="onSubmit($event)" + (clickSuggestion)="onClick($event)" (findSuggestions)="findSuggestions($event)" ngDefaultControl> diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index e2e57e7370..3c15eff127 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -9,6 +9,6 @@ - {{filterValue.count}} - + {{filterValue.count}} + diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 4f226add54..ada1bccd63 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -234,33 +234,16 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @param data The string from the input field */ onSubmit(data: any) { - if (data.match(new RegExp(`^.+,(equals|query|authority)$`))) { - this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { - if (isNotEmpty(data)) { - this.router.navigate(this.getSearchLinkParts(), { - queryParams: - { - [this.filterConfig.paramName]: [ - ...selectedValues.map((facet) => this.getFacetValue(facet)), - data - ] - }, - queryParamsHandling: 'merge' - }); - this.filter = ''; - } - this.filterSearchResults = observableOf([]); - } - ); - } + this.applyFilterValue(data); } /** - * On click, set the input's value to the clicked data - * @param data The value of the option that was clicked + * Submits a selected filter value to the filter + * Take the query from the InputSuggestion object + * @param data The input suggestion selected */ - onClick(data: any) { - this.filter = data; + onClick(data: InputSuggestion) { + this.applyFilterValue(data.query); } /** @@ -296,6 +279,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return rd.payload.page.map((facet) => { return { displayValue: this.getDisplayValue(facet, data), + query: this.getFacetValue(facet), value: stripOperatorFromFilterValue(this.getFacetValue(facet)) }; }); @@ -308,6 +292,31 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } } + /** + * Build the filter query using the value given and apply to the search. + * @param data The string from the input field + */ + protected applyFilterValue(data) { + if (data.match(new RegExp(`^.+,(equals|query|authority)$`))) { + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { + if (isNotEmpty(data)) { + this.router.navigate(this.getSearchLinkParts(), { + queryParams: + { + [this.filterConfig.paramName]: [ + ...selectedValues.map((facet) => this.getFacetValue(facet)), + data + ] + }, + queryParamsHandling: 'merge' + }); + this.filter = ''; + } + this.filterSearchResults = observableOf([]); + }); + } + } + /** * Retrieve facet value */ diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-filter.component.html index 230f072772..452433e165 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.html @@ -4,6 +4,7 @@ class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId" [attr.aria-expanded]="false" [attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate" + [attr.data-test]="'filter-toggle' | dsBrowserOnly" >
{{'search.filters.filter.' + filter.name + '.head'| translate}} diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts index 662b380ce8..7abe45ca8c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts @@ -13,6 +13,7 @@ import { FilterType } from '../../models/filter-type.model'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; import { SequenceService } from '../../../../core/shared/sequence.service'; +import { BrowserOnlyMockPipe } from '../../../testing/browser-only-mock.pipe'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -62,7 +63,10 @@ describe('SearchFilterComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], - declarations: [SearchFilterComponent], + declarations: [ + SearchFilterComponent, + BrowserOnlyMockPipe, + ], providers: [ { provide: SearchService, useValue: searchServiceStub }, { diff --git a/src/app/shared/search/search-filters/search-filters.component.spec.ts b/src/app/shared/search/search-filters/search-filters.component.spec.ts index f84de65fb0..ec1a51a1c4 100644 --- a/src/app/shared/search/search-filters/search-filters.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filters.component.spec.ts @@ -80,7 +80,7 @@ describe('SearchFiltersComponent', () => { expect(comp.initFilters).toHaveBeenCalledTimes(1); - refreshFiltersEmitter.next(); + refreshFiltersEmitter.next(null); expect(comp.initFilters).toHaveBeenCalledTimes(2); }); diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index c383a2fa1a..e506fd2b8e 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -1,4 +1,7 @@ +

{{ (configuration ? configuration + '.search.results.head' : 'search.results.head') | translate }}

+ +
{ expect(childElements.length).toEqual(comp.configurationList.length); }); - it('should call onSelect method when selecting an option', () => { + it('should call onSelect method when selecting an option', waitForAsync(() => { fixture.whenStable().then(() => { spyOn(comp, 'onSelect'); select = fixture.debugElement.query(By.css('select')); @@ -94,8 +94,7 @@ describe('SearchSwitchConfigurationComponent', () => { fixture.detectChanges(); expect(comp.onSelect).toHaveBeenCalled(); }); - - }); + })); it('should navigate to the route when selecting an option', () => { spyOn((comp as any), 'getSearchLinkParts').and.returnValue([MYDSPACE_ROUTE]); diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 37806aab60..f40947ad19 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -31,6 +31,7 @@ import { ViewMode } from '../../core/shared/view-mode.model'; import { SelectionConfig } from './search-results/search-results.component'; import { ListableObject } from '../object-collection/shared/listable-object.model'; import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; +import { environment } from 'src/environments/environment'; @Component({ selector: 'ds-search', @@ -355,7 +356,8 @@ export class SearchComponent implements OnInit { undefined, this.useCachedVersionIfAvailable, true, - followLink('thumbnail', { isOptional: true }) + followLink('thumbnail', { isOptional: true }), + followLink('accessStatus', { isOptional: true, shouldEmbed: environment.item.showAccessStatuses }) ).pipe(getFirstCompletedRemoteData()) .subscribe((results: RemoteData>) => { if (results.hasSucceeded && results.payload?.page?.length > 0) { diff --git a/src/app/shared/search/search.utils.spec.ts b/src/app/shared/search/search.utils.spec.ts index 75735093e8..70bf9a43a0 100644 --- a/src/app/shared/search/search.utils.spec.ts +++ b/src/app/shared/search/search.utils.spec.ts @@ -66,11 +66,11 @@ describe('Search Utils', () => { describe('addOperatorToFilterValue', () => { it('should add the operator to the value', () => { - expect(addOperatorToFilterValue('value', 'operator')).toEqual('value,operator'); + expect(addOperatorToFilterValue('value', 'equals')).toEqual('value,equals'); }); it('shouldn\'t add the operator to the value if it already contains the operator', () => { - expect(addOperatorToFilterValue('value,operator', 'operator')).toEqual('value,operator'); + expect(addOperatorToFilterValue('value,equals', 'equals')).toEqual('value,equals'); }); }); diff --git a/src/app/shared/search/search.utils.ts b/src/app/shared/search/search.utils.ts index 4d688d48eb..cfb96a5285 100644 --- a/src/app/shared/search/search.utils.ts +++ b/src/app/shared/search/search.utils.ts @@ -49,7 +49,7 @@ export function stripOperatorFromFilterValue(value: string) { * @param operator */ export function addOperatorToFilterValue(value: string, operator: string) { - if (!value.endsWith(`,${operator}`)) { + if (!value.match(new RegExp(`^.+,(equals|query|authority)$`))) { return `${value},${operator}`; } return value; diff --git a/src/app/shared/selector.util.ts b/src/app/shared/selector.util.ts new file mode 100644 index 0000000000..97ddb9af7d --- /dev/null +++ b/src/app/shared/selector.util.ts @@ -0,0 +1,27 @@ +import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; +import { hasValue } from './empty.util'; + +/** + * Export a function to return a subset of the state by key + */ +export function keySelector(parentSelector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} +/** + * Export a function to return a subset of the state + */ +export function subStateSelector(parentSelector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state) && hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 847b3910e0..0e6d1f45d8 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -7,7 +7,12 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; import { NouisliderModule } from 'ng2-nouislider'; import { - NgbDatepickerModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbTimepickerModule, NgbTooltipModule, + NgbDatepickerModule, + NgbDropdownModule, + NgbNavModule, + NgbPaginationModule, + NgbTimepickerModule, + NgbTooltipModule, NgbTypeaheadModule, } from '@ng-bootstrap/ng-bootstrap'; import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core'; @@ -16,7 +21,9 @@ import { FileUploadModule } from 'ng2-file-upload'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MomentModule } from 'ngx-moment'; import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component'; -import { ExportMetadataSelectorComponent } from './dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; +import { + ExportMetadataSelectorComponent +} from './dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; import { FileDropzoneNoUploaderComponent } from './file-dropzone-no-uploader/file-dropzone-no-uploader.component'; import { ItemListElementComponent } from './object-list/item-list-element/item-types/item/item-list-element.component'; import { EnumKeysPipe } from './utils/enum-keys-pipe'; @@ -24,13 +31,21 @@ import { FileSizePipe } from './utils/file-size-pipe'; import { MetadataFieldValidator } from './utils/metadatafield-validator.directive'; import { SafeUrlPipe } from './utils/safe-url-pipe'; import { ConsolePipe } from './utils/console.pipe'; -import { CollectionListElementComponent } from './object-list/collection-list-element/collection-list-element.component'; +import { + CollectionListElementComponent +} from './object-list/collection-list-element/collection-list-element.component'; import { CommunityListElementComponent } from './object-list/community-list-element/community-list-element.component'; -import { SearchResultListElementComponent } from './object-list/search-result-list-element/search-result-list-element.component'; +import { + SearchResultListElementComponent +} from './object-list/search-result-list-element/search-result-list-element.component'; import { ObjectListComponent } from './object-list/object-list.component'; -import { CollectionGridElementComponent } from './object-grid/collection-grid-element/collection-grid-element.component'; +import { + CollectionGridElementComponent +} from './object-grid/collection-grid-element/collection-grid-element.component'; import { CommunityGridElementComponent } from './object-grid/community-grid-element/community-grid-element.component'; -import { AbstractListableElementComponent } from './object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { + AbstractListableElementComponent +} from './object-collection/shared/object-collection-element/abstract-listable-element.component'; import { ObjectGridComponent } from './object-grid/object-grid.component'; import { ObjectCollectionComponent } from './object-collection/object-collection.component'; import { ErrorComponent } from './error/error.component'; @@ -38,7 +53,9 @@ import { LoadingComponent } from './loading/loading.component'; import { PaginationComponent } from './pagination/pagination.component'; import { ThumbnailComponent } from '../thumbnail/thumbnail.component'; import { SearchFormComponent } from './search-form/search-form.component'; -import { SearchResultGridElementComponent } from './object-grid/search-result-grid-element/search-result-grid-element.component'; +import { + SearchResultGridElementComponent +} from './object-grid/search-result-grid-element/search-result-grid-element.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { VarDirective } from './utils/var.directive'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; @@ -53,21 +70,33 @@ import { ChipsComponent } from './chips/chips.component'; import { NumberPickerComponent } from './number-picker/number-picker.component'; import { MockAdminGuard } from './mocks/admin-guard.service.mock'; import { AlertComponent } from './alert/alert.component'; -import { SearchResultDetailElementComponent } from './object-detail/my-dspace-result-detail-element/search-result-detail-element.component'; +import { + SearchResultDetailElementComponent +} from './object-detail/my-dspace-result-detail-element/search-result-detail-element.component'; import { ClaimedTaskActionsComponent } from './mydspace-actions/claimed-task/claimed-task-actions.component'; import { PoolTaskActionsComponent } from './mydspace-actions/pool-task/pool-task-actions.component'; import { ObjectDetailComponent } from './object-detail/object-detail.component'; -import { ItemDetailPreviewComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component'; -import { MyDSpaceItemStatusComponent } from './object-collection/shared/mydspace-item-status/my-dspace-item-status.component'; +import { + ItemDetailPreviewComponent +} from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component'; +import { + MyDSpaceItemStatusComponent +} from './object-collection/shared/mydspace-item-status/my-dspace-item-status.component'; import { WorkspaceitemActionsComponent } from './mydspace-actions/workspaceitem/workspaceitem-actions.component'; import { WorkflowitemActionsComponent } from './mydspace-actions/workflowitem/workflowitem-actions.component'; import { ItemSubmitterComponent } from './object-collection/shared/mydspace-item-submitter/item-submitter.component'; import { ItemActionsComponent } from './mydspace-actions/item/item-actions.component'; -import { ClaimedTaskActionsApproveComponent } from './mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component'; -import { ClaimedTaskActionsRejectComponent } from './mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component'; +import { + ClaimedTaskActionsApproveComponent +} from './mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component'; +import { + ClaimedTaskActionsRejectComponent +} from './mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component'; import { ObjNgFor } from './utils/object-ngfor.pipe'; import { BrowseByComponent } from './browse-by/browse-by.component'; -import { BrowseEntryListElementComponent } from './object-list/browse-entry-list-element/browse-entry-list-element.component'; +import { + BrowseEntryListElementComponent +} from './object-list/browse-entry-list-element/browse-entry-list-element.component'; import { DebounceDirective } from './utils/debounce.directive'; import { ClickOutsideDirective } from './utils/click-outside.directive'; import { EmphasizePipe } from './utils/emphasize.pipe'; @@ -76,53 +105,106 @@ import { CapitalizePipe } from './utils/capitalize.pipe'; import { ObjectKeysPipe } from './utils/object-keys-pipe'; import { AuthorityConfidenceStateDirective } from './authority-confidence/authority-confidence-state.directive'; import { LangSwitchComponent } from './lang-switch/lang-switch.component'; -import { PlainTextMetadataListElementComponent } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; -import { ItemMetadataListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; -import { MetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/metadata-representation-list-element.component'; +import { + PlainTextMetadataListElementComponent +} from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; +import { + ItemMetadataListElementComponent +} from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; +import { + MetadataRepresentationListElementComponent +} from './object-list/metadata-representation-list-element/metadata-representation-list-element.component'; import { ObjectValuesPipe } from './utils/object-values-pipe'; import { InListValidator } from './utils/in-list-validator.directive'; import { AutoFocusDirective } from './utils/auto-focus.directive'; import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component'; import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component'; import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.component'; -import { CreateCommunityParentSelectorComponent } from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; -import { CreateItemParentSelectorComponent } from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; -import { CreateCollectionParentSelectorComponent } from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; -import { CommunitySearchResultListElementComponent } from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component'; -import { CollectionSearchResultListElementComponent } from './object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component'; -import { EditItemSelectorComponent } from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; -import { EditCommunitySelectorComponent } from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; -import { EditCollectionSelectorComponent } from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; -import { ItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component'; -import { MetadataFieldWrapperComponent } from '../item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { + CreateCommunityParentSelectorComponent +} from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { + CreateItemParentSelectorComponent +} from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { + CreateCollectionParentSelectorComponent +} from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { + CommunitySearchResultListElementComponent +} from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component'; +import { + CollectionSearchResultListElementComponent +} from './object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component'; +import { + EditItemSelectorComponent +} from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { + EditCommunitySelectorComponent +} from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { + EditCollectionSelectorComponent +} from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { + ItemListPreviewComponent +} from './object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component'; +import { + MetadataFieldWrapperComponent +} from '../item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import { MetadataValuesComponent } from '../item-page/field-components/metadata-values/metadata-values.component'; import { RoleDirective } from './roles/role.directive'; import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; -import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; -import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; -import { CollectionSearchResultGridElementComponent } from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; -import { CommunitySearchResultGridElementComponent } from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; +import { + ClaimedTaskActionsReturnToPoolComponent +} from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; +import { + ItemDetailPreviewFieldComponent +} from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; +import { + CollectionSearchResultGridElementComponent +} from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; +import { + CommunitySearchResultGridElementComponent +} from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; import { PageSizeSelectorComponent } from './page-size-selector/page-size-selector.component'; import { AbstractTrackableComponent } from './trackable/abstract-trackable.component'; -import { ComcolMetadataComponent } from './comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { + ComcolMetadataComponent +} from './comcol/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { ItemSelectComponent } from './object-select/item-select/item-select.component'; import { CollectionSelectComponent } from './object-select/collection-select/collection-select.component'; -import { FilterInputSuggestionsComponent } from './input-suggestions/filter-suggestions/filter-input-suggestions.component'; -import { DsoInputSuggestionsComponent } from './input-suggestions/dso-input-suggestions/dso-input-suggestions.component'; +import { + FilterInputSuggestionsComponent +} from './input-suggestions/filter-suggestions/filter-input-suggestions.component'; +import { + DsoInputSuggestionsComponent +} from './input-suggestions/dso-input-suggestions/dso-input-suggestions.component'; import { ItemGridElementComponent } from './object-grid/item-grid-element/item-types/item/item-grid-element.component'; import { TypeBadgeComponent } from './object-list/type-badge/type-badge.component'; -import { MetadataRepresentationLoaderComponent } from './metadata-representation/metadata-representation-loader.component'; +import { AccessStatusBadgeComponent } from './object-list/access-status-badge/access-status-badge.component'; +import { + MetadataRepresentationLoaderComponent +} from './metadata-representation/metadata-representation-loader.component'; import { MetadataRepresentationDirective } from './metadata-representation/metadata-representation.directive'; -import { ListableObjectComponentLoaderComponent } from './object-collection/shared/listable-object/listable-object-component-loader.component'; -import { ItemSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component'; +import { + ListableObjectComponentLoaderComponent +} from './object-collection/shared/listable-object/listable-object-component-loader.component'; +import { + ItemSearchResultListElementComponent +} from './object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component'; import { ListableObjectDirective } from './object-collection/shared/listable-object/listable-object.directive'; -import { ItemMetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; +import { + ItemMetadataRepresentationListElementComponent +} from './object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; import { PageWithSidebarComponent } from './sidebar/page-with-sidebar.component'; import { SidebarDropdownComponent } from './sidebar/sidebar-dropdown.component'; import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.component'; import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; -import { SelectableListItemControlComponent } from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; -import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; +import { + SelectableListItemControlComponent +} from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; +import { + ImportableListItemControlComponent +} from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { ItemVersionsComponent } from './item/item-versions/item-versions.component'; import { SortablejsModule } from 'ngx-sortablejs'; import { LogInContainerComponent } from './log-in/container/log-in-container.component'; @@ -135,10 +217,16 @@ import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-ve import { FileValidator } from './utils/require-file.validator'; import { FileValueAccessorDirective } from './utils/file-value-accessor.directive'; import { FileSectionComponent } from '../item-page/simple/field-components/file-section/file-section.component'; -import { ModifyItemOverviewComponent } from '../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; -import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; +import { + ModifyItemOverviewComponent +} from '../item-page/edit-item-page/modify-item-overview/modify-item-overview.component'; +import { + ClaimedTaskActionsLoaderComponent +} from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; -import { ClaimedTaskActionsEditMetadataComponent } from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; +import { + ClaimedTaskActionsEditMetadataComponent +} from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; import { ImpersonateNavbarComponent } from './impersonate-navbar/impersonate-navbar.component'; import { NgForTrackByIdDirective } from './ng-for-track-by-id.directive'; import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; @@ -146,37 +234,65 @@ import { CollectionDropdownComponent } from './collection-dropdown/collection-dr import { EntityDropdownComponent } from './entity-dropdown/entity-dropdown.component'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; import { CurationFormComponent } from '../curation-form/curation-form.component'; -import { PublicationSidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/item-types/publication/publication-sidebar-search-list-element.component'; -import { SidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/sidebar-search-list-element.component'; -import { CollectionSidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component'; -import { CommunitySidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component'; -import { AuthorizedCollectionSelectorComponent } from './dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; +import { + PublicationSidebarSearchListElementComponent +} from './object-list/sidebar-search-list-element/item-types/publication/publication-sidebar-search-list-element.component'; +import { + SidebarSearchListElementComponent +} from './object-list/sidebar-search-list-element/sidebar-search-list-element.component'; +import { + CollectionSidebarSearchListElementComponent +} from './object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component'; +import { + CommunitySidebarSearchListElementComponent +} from './object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component'; +import { + AuthorizedCollectionSelectorComponent +} from './dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DsoPageEditButtonComponent } from './dso-page/dso-page-edit-button/dso-page-edit-button.component'; import { DsoPageVersionButtonComponent } from './dso-page/dso-page-version-button/dso-page-version-button.component'; import { HoverClassDirective } from './hover-class.directive'; -import { ValidationSuggestionsComponent } from './input-suggestions/validation-suggestions/validation-suggestions.component'; +import { + ValidationSuggestionsComponent +} from './input-suggestions/validation-suggestions/validation-suggestions.component'; import { ItemAlertsComponent } from './item/item-alerts/item-alerts.component'; -import { ItemSearchResultGridElementComponent } from './object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component'; +import { + ItemSearchResultGridElementComponent +} from './object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component'; import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component'; -import { GenericItemPageFieldComponent } from '../item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { MetadataRepresentationListComponent } from '../item-page/simple/metadata-representation-list/metadata-representation-list.component'; +import { + GenericItemPageFieldComponent +} from '../item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { + MetadataRepresentationListComponent +} from '../item-page/simple/metadata-representation-list/metadata-representation-list.component'; import { RelatedItemsComponent } from '../item-page/simple/related-items/related-items-component'; import { LinkMenuItemComponent } from './menu/menu-item/link-menu-item.component'; import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.component'; import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component'; import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'; -import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; -import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; +import { + ItemVersionsSummaryModalComponent +} from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { + ItemVersionsDeleteModalComponent +} from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component'; -import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; +import { + BitstreamRequestACopyPageComponent +} from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; import { LogInOidcComponent } from './log-in/methods/oidc/log-in-oidc.component'; import { ThemedItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component'; -import { ClaimItemSelectorComponent } from './dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component'; +import { RSSComponent } from './rss-feed/rss.component'; import { ExternalLinkMenuItemComponent } from './menu/menu-item/external-link-menu-item.component'; +import { DsoPageOrcidButtonComponent } from './dso-page/dso-page-orcid-button/dso-page-orcid-button.component'; +import { LogInOrcidComponent } from './log-in/methods/orcid/log-in-orcid.component'; +import { BrowserOnlyPipe } from './utils/browser-only.pipe'; +import { PersonPageClaimButtonComponent } from './dso-page/person-page-claim-button/person-page-claim-button.component'; +import { SearchExportCsvComponent } from './search/search-export-csv/search-export-csv.component'; const MODULES = [ - // Do NOT include UniversalModule, HttpModule, or JsonpModule here CommonModule, SortablejsModule, FileUploadModule, @@ -216,7 +332,8 @@ const PIPES = [ ObjectKeysPipe, ObjectValuesPipe, ConsolePipe, - ObjNgFor + ObjNgFor, + BrowserOnlyPipe, ]; const COMPONENTS = [ @@ -239,6 +356,7 @@ const COMPONENTS = [ AbstractListableElementComponent, ObjectCollectionComponent, PaginationComponent, + RSSComponent, SearchFormComponent, PageWithSidebarComponent, SidebarDropdownComponent, @@ -284,6 +402,7 @@ const COMPONENTS = [ CollectionSearchResultGridElementComponent, CommunitySearchResultGridElementComponent, + SearchExportCsvComponent, PageSizeSelectorComponent, ListableObjectComponentLoaderComponent, CollectionListElementComponent, @@ -294,6 +413,7 @@ const COMPONENTS = [ AbstractTrackableComponent, ComcolMetadataComponent, TypeBadgeComponent, + AccessStatusBadgeComponent, BrowseByComponent, AbstractTrackableComponent, @@ -306,6 +426,7 @@ const COMPONENTS = [ LogInShibbolethComponent, LogInOidcComponent, + LogInOrcidComponent, LogInPasswordComponent, LogInContainerComponent, ItemVersionsComponent, @@ -342,9 +463,7 @@ const COMPONENTS = [ CollectionSidebarSearchListElementComponent, CommunitySidebarSearchListElementComponent, SearchNavbarComponent, - ScopeSelectorModalComponent, - - ClaimItemSelectorComponent + ScopeSelectorModalComponent ]; const ENTRY_COMPONENTS = [ @@ -380,6 +499,7 @@ const ENTRY_COMPONENTS = [ LogInPasswordComponent, LogInShibbolethComponent, LogInOidcComponent, + LogInOrcidComponent, BundleListElementComponent, ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, @@ -401,7 +521,6 @@ const ENTRY_COMPONENTS = [ OnClickMenuItemComponent, TextMenuItemComponent, ScopeSelectorModalComponent, - ClaimItemSelectorComponent, ExternalLinkMenuItemComponent ]; @@ -410,10 +529,12 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ MetadataValuesComponent, DsoPageEditButtonComponent, DsoPageVersionButtonComponent, + PersonPageClaimButtonComponent, ItemAlertsComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, RelatedItemsComponent, + DsoPageOrcidButtonComponent ]; diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.html b/src/app/shared/sidebar/filter/sidebar-filter.component.html index bd392aa715..79afaa7583 100644 --- a/src/app/shared/sidebar/filter/sidebar-filter.component.html +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.html @@ -1,5 +1,5 @@
-
+
{{ label | translate }}
diff --git a/src/app/shared/testing/active-router.stub.ts b/src/app/shared/testing/active-router.stub.ts index aa4bfce438..13cb81b42e 100644 --- a/src/app/shared/testing/active-router.stub.ts +++ b/src/app/shared/testing/active-router.stub.ts @@ -54,6 +54,7 @@ export class ActivatedRouteStub { get snapshot() { return { params: this.testParams, + paramMap: convertToParamMap(this.params), queryParamMap: convertToParamMap(this.testParams) }; } diff --git a/src/app/shared/testing/auth-request-service.stub.ts b/src/app/shared/testing/auth-request-service.stub.ts index 0dc57427dd..0094324518 100644 --- a/src/app/shared/testing/auth-request-service.stub.ts +++ b/src/app/shared/testing/auth-request-service.stub.ts @@ -34,6 +34,9 @@ export class AuthRequestServiceStub { }, eperson: { href: this.mockUser._links.self.href + }, + specialGroups: { + href: this.mockUser._links.self.href } }; } else { @@ -62,6 +65,9 @@ export class AuthRequestServiceStub { }, eperson: { href: this.mockUser._links.self.href + }, + specialGroups: { + href: this.mockUser._links.self.href } }; } else { diff --git a/src/app/shared/testing/browser-only-mock.pipe.ts b/src/app/shared/testing/browser-only-mock.pipe.ts new file mode 100644 index 0000000000..12e5a7b2d7 --- /dev/null +++ b/src/app/shared/testing/browser-only-mock.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Support dsBrowserOnly in unit tests. + */ +@Pipe({ + name: 'dsBrowserOnly' +}) +export class BrowserOnlyMockPipe implements PipeTransform { + transform(value: string): string | undefined { + return value; + } +} diff --git a/src/app/shared/testing/hal-endpoint-service.stub.ts b/src/app/shared/testing/hal-endpoint-service.stub.ts index 19f95d577c..753efcdb5d 100644 --- a/src/app/shared/testing/hal-endpoint-service.stub.ts +++ b/src/app/shared/testing/hal-endpoint-service.stub.ts @@ -1,9 +1,13 @@ import { of as observableOf } from 'rxjs'; +import { hasValue } from '../empty.util'; export class HALEndpointServiceStub { constructor(private url: string) {} - getEndpoint(path: string) { + getEndpoint(path: string, startHref?: string) { + if (hasValue(startHref)) { + return observableOf(startHref + '/' + path); + } return observableOf(this.url + '/' + path); } } diff --git a/src/app/shared/testing/menu-service.stub.ts b/src/app/shared/testing/menu-service.stub.ts index 14eccfda67..926232bad0 100644 --- a/src/app/shared/testing/menu-service.stub.ts +++ b/src/app/shared/testing/menu-service.stub.ts @@ -1,5 +1,6 @@ import { Observable, of as observableOf } from 'rxjs'; import { MenuSection } from '../menu/menu-section.model'; +import { MenuState } from '../menu/menu-state.model'; import { MenuID } from '../menu/menu-id.model'; export class MenuServiceStub { @@ -77,6 +78,10 @@ export class MenuServiceStub { return observableOf(true); } + getMenu(id: MenuID): Observable { // todo: resolve import + return observableOf({} as MenuState); + } + getMenuTopSections(id: MenuID): Observable { return observableOf([this.visibleSection1, this.visibleSection2]); } diff --git a/src/app/shared/testing/search-configuration-service.stub.ts b/src/app/shared/testing/search-configuration-service.stub.ts index 80744ba59a..78b358f0d4 100644 --- a/src/app/shared/testing/search-configuration-service.stub.ts +++ b/src/app/shared/testing/search-configuration-service.stub.ts @@ -17,6 +17,10 @@ export class SearchConfigurationServiceStub { return observableOf('test-id'); } + getCurrentQuery(a) { + return observableOf(a); + } + getCurrentConfiguration(a) { return observableOf(a); } diff --git a/src/app/shared/testing/sections-service.stub.ts b/src/app/shared/testing/sections-service.stub.ts index 1628453bc8..b687c512c2 100644 --- a/src/app/shared/testing/sections-service.stub.ts +++ b/src/app/shared/testing/sections-service.stub.ts @@ -20,4 +20,5 @@ export class SectionsServiceStub { computeSectionConfiguredMetadata = jasmine.createSpy('computeSectionConfiguredMetadata'); getShownSectionErrors = jasmine.createSpy('getShownSectionErrors'); getSectionServerErrors = jasmine.createSpy('getSectionServerErrors'); + getIsInformational = jasmine.createSpy('getIsInformational'); } diff --git a/src/app/shared/testing/special-group.mock.ts b/src/app/shared/testing/special-group.mock.ts new file mode 100644 index 0000000000..f1102e0584 --- /dev/null +++ b/src/app/shared/testing/special-group.mock.ts @@ -0,0 +1,56 @@ +import { Observable } from 'rxjs'; + +import { EPersonMock } from './eperson.mock'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { Group } from '../../core/eperson/models/group.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { RemoteData } from '../../core/data/remote-data'; + +export const SpecialGroupMock2: Group = Object.assign(new Group(), { + handle: null, + subgroups: [], + epersons: [], + permanent: true, + selfRegistered: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/epersons' } + }, + _name: 'testgroupname2', + id: 'testgroupid2', + uuid: 'testgroupid2', + type: 'specialGroups', + // object: createSuccessfulRemoteDataObject$({ name: 'testspecialGroupsid2objectName'}) +}); + +export const SpecialGroupMock: Group = Object.assign(new Group(), { + handle: null, + subgroups: [SpecialGroupMock2], + epersons: [EPersonMock], + selfRegistered: false, + permanent: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/epersons' } + }, + _name: 'testgroupname', + id: 'testgroupid', + uuid: 'testgroupid', + type: 'specialGroups', +}); + +export const SpecialGroupDataMock: RemoteData> = createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock])); +export const SpecialGroupDataMock$: Observable>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock])); +export const EmptySpecialGroupDataMock$: Observable>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); + + + diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index b43adde8ae..7d45dd41f3 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -5,6 +5,7 @@ import { SharedModule } from '../shared.module'; import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive.stub'; import { QueryParamsDirectiveStub } from './query-params-directive.stub'; import { RouterLinkDirectiveStub } from './router-link-directive.stub'; +import { BrowserOnlyMockPipe } from './browser-only-mock.pipe'; /** * This module isn't used. It serves to prevent the AoT compiler @@ -21,7 +22,8 @@ import { RouterLinkDirectiveStub } from './router-link-directive.stub'; QueryParamsDirectiveStub, MySimpleItemActionComponent, RouterLinkDirectiveStub, - NgComponentOutletDirectiveStub + NgComponentOutletDirectiveStub, + BrowserOnlyMockPipe, ], exports: [ QueryParamsDirectiveStub diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.html b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.html index 76595c901f..aab26c87e8 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.html +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.html @@ -1,5 +1,13 @@
-
- -
+
+ +
+ +
diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss index e69de29bb2..e045b197d2 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss @@ -0,0 +1,11 @@ +.content:not(.truncated) ~ button.expandButton { + display: none; +} + +.btn:focus { + box-shadow: none !important; +} + +.removeFaded.content::after { + display: none; +} diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.spec.ts b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.spec.ts index 1b3cdad33e..08d3e18117 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.spec.ts +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.spec.ts @@ -4,10 +4,17 @@ import { TruncatablePartComponent } from './truncatable-part.component'; import { TruncatableService } from '../truncatable.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { getMockTranslateService } from '../../mocks/translate.service.mock'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { mockTruncatableService } from '../../mocks/mock-trucatable.service'; +import { By } from '@angular/platform-browser'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; describe('TruncatablePartComponent', () => { let comp: TruncatablePartComponent; let fixture: ComponentFixture; + let translateService: TranslateService; const id1 = '123'; const id2 = '456'; @@ -22,10 +29,19 @@ describe('TruncatablePartComponent', () => { } }; beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [NoopAnimationsModule], + translateService = getMockTranslateService(); + void TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], declarations: [TruncatablePartComponent], providers: [ + { provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: TruncatableService, useValue: truncatableServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] @@ -52,6 +68,11 @@ describe('TruncatablePartComponent', () => { it('lines should equal minlines', () => { expect((comp as any).lines).toEqual(comp.minLines.toString()); }); + + it('collapseButton should be hidden', () => { + const a = fixture.debugElement.query(By.css('.collapseButton')); + expect(a).toBeNull(); + }); }); describe('When the item is expanded', () => { @@ -72,5 +93,63 @@ describe('TruncatablePartComponent', () => { fixture.detectChanges(); expect((comp as any).lines).toEqual('none'); }); + + it('collapseButton should be shown', () => { + (comp as any).setLines(); + (comp as any).expandable = true; + fixture.detectChanges(); + const a = fixture.debugElement.query(By.css('.collapseButton')); + expect(a).not.toBeNull(); + }); }); }); + +describe('TruncatablePartComponent', () => { + let comp: TruncatablePartComponent; + let fixture: ComponentFixture; + let translateService: TranslateService; + const identifier = '1234567890'; + let truncatableService; + beforeEach(waitForAsync(() => { + translateService = getMockTranslateService(); + void TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [TruncatablePartComponent], + providers: [ + { provide: NativeWindowService, useValue: new NativeWindowRef() }, + { provide: TruncatableService, useValue: mockTruncatableService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(TruncatablePartComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TruncatablePartComponent); + comp = fixture.componentInstance; // TruncatablePartComponent test instance + comp.id = identifier; + fixture.detectChanges(); + truncatableService = (comp as any).service; + }); + + describe('When toggle is called', () => { + beforeEach(() => { + spyOn(truncatableService, 'toggle'); + comp.toggle(); + }); + + it('should call toggle on the TruncatableService', () => { + expect(truncatableService.toggle).toHaveBeenCalledWith(identifier); + }); + }); + +}); diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts index 2a375e95d9..790bd5985d 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { AfterViewChecked, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { TruncatableService } from '../truncatable.service'; import { hasValue } from '../../empty.util'; @@ -12,7 +12,7 @@ import { hasValue } from '../../empty.util'; * Component that truncates/clamps a piece of text * It needs a TruncatableComponent parent to identify it's current state */ -export class TruncatablePartComponent implements OnInit, OnDestroy { +export class TruncatablePartComponent implements AfterViewChecked, OnInit, OnDestroy { /** * Number of lines shown when the part is collapsed */ @@ -40,6 +40,17 @@ export class TruncatablePartComponent implements OnInit, OnDestroy { @Input() background = 'default'; + /** + * A boolean representing if to show or not the show/collapse toggle. + * This value must have the same value as the parent TruncatableComponent + */ + @Input() showToggle = true; + + /** + * The view on the truncatable part + */ + @ViewChild('content', {static: true}) content: ElementRef; + /** * Current amount of lines shown of this part */ @@ -49,9 +60,16 @@ export class TruncatablePartComponent implements OnInit, OnDestroy { * Subscription to unsubscribe from */ private sub; + /** + * store variable used for local to expand collapse + */ + expand = false; + /** + * variable to check if expandable + */ + expandable = false; - public constructor(private service: TruncatableService) { - } + public constructor(private service: TruncatableService) {} /** * Initialize lines variable @@ -67,12 +85,57 @@ export class TruncatablePartComponent implements OnInit, OnDestroy { this.sub = this.service.isCollapsed(this.id).subscribe((collapsed: boolean) => { if (collapsed) { this.lines = this.minLines.toString(); + this.expand = false; } else { this.lines = this.maxLines < 0 ? 'none' : this.maxLines.toString(); + this.expand = true; } }); } + ngAfterViewChecked() { + this.truncateElement(); + } + + /** + * Expands the truncatable when it's collapsed, collapses it when it's expanded + */ + public toggle() { + this.service.toggle(this.id); + this.expandable = !this.expandable; + } + + /** + * check for the truncate element + */ + public truncateElement() { + if (this.showToggle) { + const entry = this.content.nativeElement; + if (entry.scrollHeight > entry.offsetHeight) { + if (entry.children.length > 0) { + if (entry.children[entry.children.length - 1].offsetHeight > entry.offsetHeight) { + entry.classList.add('truncated'); + entry.classList.remove('removeFaded'); + } else { + entry.classList.remove('truncated'); + entry.classList.add('removeFaded'); + } + } else { + if (entry.innerText.length > 0) { + entry.classList.add('truncated'); + entry.classList.remove('removeFaded'); + } else { + entry.classList.remove('truncated'); + entry.classList.add('removeFaded'); + } + } + } else { + entry.classList.remove('truncated'); + entry.classList.add('removeFaded'); + } + } + } + /** * Unsubscribe from the subscription */ diff --git a/src/app/shared/truncatable/truncatable.component.html b/src/app/shared/truncatable/truncatable.component.html index b524e5e754..342e79f638 100644 --- a/src/app/shared/truncatable/truncatable.component.html +++ b/src/app/shared/truncatable/truncatable.component.html @@ -1,3 +1,3 @@ -
+
diff --git a/src/app/shared/truncatable/truncatable.component.spec.ts b/src/app/shared/truncatable/truncatable.component.spec.ts index b539ab0d56..29100e50d2 100644 --- a/src/app/shared/truncatable/truncatable.component.spec.ts +++ b/src/app/shared/truncatable/truncatable.component.spec.ts @@ -70,15 +70,4 @@ describe('TruncatableComponent', () => { }); }); - describe('When toggle is called', () => { - beforeEach(() => { - spyOn(truncatableService, 'toggle'); - comp.toggle(); - }); - - it('should call toggle on the TruncatableService', () => { - expect(truncatableService.toggle).toHaveBeenCalledWith(identifier); - }); - }); - }); diff --git a/src/app/shared/truncatable/truncatable.component.ts b/src/app/shared/truncatable/truncatable.component.ts index e22ce4441e..8fca300cd4 100644 --- a/src/app/shared/truncatable/truncatable.component.ts +++ b/src/app/shared/truncatable/truncatable.component.ts @@ -1,6 +1,4 @@ -import { - Component, Input -} from '@angular/core'; +import { AfterViewChecked, Component, ElementRef, Input, OnInit } from '@angular/core'; import { TruncatableService } from './truncatable.service'; @Component({ @@ -13,7 +11,7 @@ import { TruncatableService } from './truncatable.service'; /** * Component that represents a section with one or more truncatable parts that all listen to this state */ -export class TruncatableComponent { +export class TruncatableComponent implements OnInit, AfterViewChecked { /** * Is true when all truncatable parts in this truncatable should be expanded on loading */ @@ -29,7 +27,13 @@ export class TruncatableComponent { */ @Input() onHover = false; - public constructor(private service: TruncatableService) { + /** + * A boolean representing if to show or not the show/collapse toggle + * This value must have the same value as the children TruncatablePartComponent + */ + @Input() showToggle = true; + + public constructor(private service: TruncatableService, private el: ElementRef,) { } /** @@ -61,11 +65,18 @@ export class TruncatableComponent { } } - /** - * Expands the truncatable when it's collapsed, collapses it when it's expanded - */ - public toggle() { - this.service.toggle(this.id); + ngAfterViewChecked() { + if (this.showToggle) { + const truncatedElements = this.el.nativeElement.querySelectorAll('.truncated'); + if (truncatedElements?.length > 0) { + const truncateElements = this.el.nativeElement.querySelectorAll('.dont-break-out'); + for (let i = 0; i < (truncateElements.length - 1); i++) { + truncateElements[i].classList.remove('truncated'); + truncateElements[i].classList.add('notruncatable'); + } + truncateElements[truncateElements.length - 1].classList.add('truncated'); + } + } } } diff --git a/src/app/shared/utils/browser-only.pipe.ts b/src/app/shared/utils/browser-only.pipe.ts new file mode 100644 index 0000000000..e3ee3df69d --- /dev/null +++ b/src/app/shared/utils/browser-only.pipe.ts @@ -0,0 +1,35 @@ +import { Inject, Pipe, PipeTransform, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +/** + * A pipe that only returns its input when run in the browser. + * Used to distinguish client-side rendered content from server-side rendered content. + * + * When used with attributes as in + * ``` + * [attr.data-test]="'something' | dsBrowserOnly" + * ``` + * the server-side rendered HTML will not contain the `data-test` attribute. + * When rendered client-side, the HTML will contain `data-test="something"` + * + * This can be useful for end-to-end testing elements that need JS (that isn't included in SSR HTML) to function: + * By depending on `dsBrowserOnly` attributes in tests we can make sure we wait + * until such components are fully interactive before trying to interact with them. + */ +@Pipe({ + name: 'dsBrowserOnly' +}) +export class BrowserOnlyPipe implements PipeTransform { + constructor( + @Inject(PLATFORM_ID) private platformID: any, + ) { + } + + transform(value: string): string | undefined { + if (isPlatformBrowser((this.platformID))) { + return value; + } else { + return undefined; + } + } +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.html b/src/app/shared/view-mode-switch/view-mode-switch.component.html index 2863a4832b..5f70bc699c 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.html +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.html @@ -7,7 +7,7 @@ routerLinkActive="active" [class.active]="currentMode === viewModeEnum.ListElement" class="btn btn-secondary" - data-test="list-view"> + [attr.data-test]="'list-view' | dsBrowserOnly"> + [attr.data-test]="'grid-view' | dsBrowserOnly"> + [attr.data-test]="'detail-view' | dsBrowserOnly">
diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index d361857413..5780ea5cf3 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -9,6 +9,7 @@ import { SearchService } from '../../core/shared/search/search.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { SearchServiceStub } from '../testing/search-service.stub'; import { ViewMode } from '../../core/shared/view-mode.model'; +import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; @Component({ template: '' }) class DummyComponent { @@ -36,7 +37,8 @@ describe('ViewModeSwitchComponent', () => { ], declarations: [ ViewModeSwitchComponent, - DummyComponent + DummyComponent, + BrowserOnlyMockPipe, ], providers: [ { provide: SearchService, useValue: searchService }, diff --git a/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts index 9d093874b8..23ac6fcf8f 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts @@ -38,7 +38,7 @@ export class VocabularyTreeFlatDataSource extends DataSource { this._treeControl.expansionModel.changed, this._flattenedData ]; - return merge(...changes).pipe(map(() => { + return merge(...changes).pipe(map((): F[] => { this._expandedData.next( this._treeFlattener.expandFlattenedNodes(this._flattenedData.value, this._treeControl)); return this._expandedData.value; diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index ef84a290dd..c1c64c80bd 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { TestScheduler } from 'rxjs/testing'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { getTestScheduler, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { VocabularyTreeviewService } from './vocabulary-treeview.service'; import { VocabularyService } from '../../core/submission/vocabularies/vocabulary.service'; @@ -14,6 +14,8 @@ import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models import { buildPaginatedList } from '../../core/data/paginated-list.model'; import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { expand, map, switchMap } from 'rxjs/operators'; +import { from as observableFrom } from 'rxjs'; describe('VocabularyTreeviewService test suite', () => { @@ -320,10 +322,25 @@ describe('VocabularyTreeviewService test suite', () => { scheduler.schedule(() => service.searchByQuery(vocabularyOptions)); scheduler.flush(); - searchChildNode.childrenChange.next([searchChildNode3]); - searchItemNode.childrenChange.next([searchChildNode]); - expect(serviceAsAny.dataChange.value.length).toEqual(1); - expect(serviceAsAny.dataChange.value).toEqual([searchItemNode]); + // We can't check the tree by comparing root TreeviewNodes directly in this particular test; + // Since RxJs 7, BehaviorSubjects can no longer be reliably compared because of the new currentObservers property + // (see https://github.com/ReactiveX/rxjs/pull/6842) + const levels$ = serviceAsAny.dataChange.pipe( + expand((nodes: TreeviewNode[]) => { // recursively apply: + return observableFrom(nodes).pipe( // for each node in the array... + switchMap(node => node.childrenChange) // ...map it to the array its child nodes. + ); // because we only have one child per node in this case, + }), // this results in an array of nodes for each level of the tree. + map((nodes: TreeviewNode[]) => nodes.map(node => node.item)), // finally, replace nodes with their vocab entries + ); + + // Confirm that this corresponds to the hierarchy we set up above + expect(levels$).toBeObservable(cold('-(abcd)', { + a: [item], + b: [child], + c: [child3], + d: [] // ensure that grandchild has no children & the recursion stopped there + })); }); }); diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index c9a267a76f..0c6bc2bc51 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -1,5 +1,5 @@ import { GoogleAnalyticsService } from './google-analytics.service'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { createFailedRemoteDataObject$, diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 94e5ad20af..0b52f54c4f 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@angular/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { isEmpty } from '../shared/empty.util'; diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html index 7013c55667..e898e1c220 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.html +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -3,6 +3,7 @@ +
+ {{version.embargo.amount}} + {{version.embargo?.units[0]}} + + {{ + 'submission.sections.sherpa.publisher.policy.noembargo' | translate }} + + + + + + {{version.locations[0]}} +{{version.locations.length-1}} + + + {{ + 'submission.sections.sherpa.publisher.policy.nolocation' | translate }} + + +
+
+
+ + +
+
+
+
+
+
+

{{ + 'submission.sections.sherpa.publisher.policy.embargo' | translate }}

+
+
+

{{version.embargo.amount}} + {{version.embargo.units}}

+ +

{{ 'submission.sections.sherpa.publisher.policy.noembargo' | translate }}

+
+
+
+
+
+

{{ + 'submission.sections.sherpa.publisher.policy.license' | translate }}

+
+
+

{{license}}

+
+
+
+
+

{{ + 'submission.sections.sherpa.publisher.policy.prerequisites' | translate }}

+
+
+

{{prerequisite}}

+
+
+
+
+

{{ + 'submission.sections.sherpa.publisher.policy.location' | translate }}

+
+
+

{{location}}

+
+
+
+
+

{{ + 'submission.sections.sherpa.publisher.policy.conditions' | translate }}

+
+
+

{{condition}}

+
+
+
+
+
\ No newline at end of file diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.scss b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts new file mode 100644 index 0000000000..b65cb5e00f --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.spec.ts @@ -0,0 +1,57 @@ +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContentAccordionComponent } from './content-accordion.component'; + +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; + +describe('ContentAccordionComponent', () => { + let component: ContentAccordionComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + NgbCollapseModule + ], + declarations: [ContentAccordionComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ContentAccordionComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.isCollapsed = false; + component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[0]; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show 2 rows', () => { + component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[0]; + fixture.detectChanges(); + expect(de.queryAll(By.css('.row')).length).toEqual(2); + }); + + it('should show 5 rows', () => { + component.version = SherpaDataResponse.sherpaResponse.journals[0].policies[0].permittedVersions[2]; + fixture.detectChanges(); + expect(de.queryAll(By.css('.row')).length).toEqual(5); + }); +}); diff --git a/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts new file mode 100644 index 0000000000..0e7bc863ad --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/content-accordion/content-accordion.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; + +import { PermittedVersions } from '../../../../core/submission/models/sherpa-policies-details.model'; + +/** + * This component represents a section that contains the inner accordions for the publisher policy versions. + */ +@Component({ + selector: 'ds-content-accordion', + templateUrl: './content-accordion.component.html', + styleUrls: ['./content-accordion.component.scss'] +}) +export class ContentAccordionComponent { + /** + * PermittedVersions to show information from + */ + @Input() version: PermittedVersions; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = true; +} diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html new file mode 100644 index 0000000000..15dd4d7286 --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.html @@ -0,0 +1,39 @@ +
+
+
+

{{ 'submission.sections.sherpa.record.information.id' | translate }}

+
+
+

{{metadata.id}} +

+
+
+
+
+

{{ 'submission.sections.sherpa.record.information.date.created' | translate }}

+
+
+

{{metadata.dateCreated | date: 'd MMMM Y H:mm:ss zzzz' }} +

+
+
+
+
+

{{ 'submission.sections.sherpa.record.information.date.modified' | translate }}

+
+
+

{{metadata.dateModified| date: 'd MMMM Y H:mm:ss zzzz' }} +

+
+
+
+
+

{{ 'submission.sections.sherpa.record.information.uri' | translate }}

+
+ +
+
\ No newline at end of file diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.scss b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts new file mode 100644 index 0000000000..9a60a6d010 --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.spec.ts @@ -0,0 +1,47 @@ +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MetadataInformationComponent } from './metadata-information.component'; + +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; + +describe('MetadataInformationComponent', () => { + let component: MetadataInformationComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [MetadataInformationComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataInformationComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.metadata = SherpaDataResponse.sherpaResponse.metadata; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show 4 rows', () => { + expect(de.queryAll(By.css('.row')).length).toEqual(4); + }); + +}); diff --git a/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts new file mode 100644 index 0000000000..307c18d8cc --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/metadata-information/metadata-information.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +import { Metadata } from '../../../../core/submission/models/sherpa-policies-details.model'; + +/** + * This component represents a section that contains the matadata informations. + */ +@Component({ + selector: 'ds-metadata-information', + templateUrl: './metadata-information.component.html', + styleUrls: ['./metadata-information.component.scss'] +}) +export class MetadataInformationComponent { + /** + * Metadata to show information from + */ + @Input() metadata: Metadata; + +} diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html new file mode 100644 index 0000000000..3c35da8f08 --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.html @@ -0,0 +1,64 @@ +
+
+
+

{{'submission.sections.sherpa.publication.information.title' | translate}}

+
+
+

{{title}} +

+
+
+
+
+

{{'submission.sections.sherpa.publication.information.issns' | translate}}

+
+
+

{{issn}} +

+
+
+
+
+

{{'submission.sections.sherpa.publication.information.url' | translate}}

+
+ +
+
+
+

{{'submission.sections.sherpa.publication.information.publishers' | translate}}

+
+ +
+
+
+

{{'submission.sections.sherpa.publication.information.romeoPub' | translate}}

+
+
+

+ {{journal.romeoPub}} +

+
+
+
+
+

{{'submission.sections.sherpa.publication.information.zetoPub' | translate}}

+
+
+

+ {{journal.zetoPub}} +

+
+
+
\ No newline at end of file diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.scss b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts new file mode 100644 index 0000000000..c5dc896858 --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.spec.ts @@ -0,0 +1,47 @@ +import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PublicationInformationComponent } from './publication-information.component'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; + +describe('PublicationInformationComponent', () => { + let component: PublicationInformationComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [PublicationInformationComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PublicationInformationComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.journal = SherpaDataResponse.sherpaResponse.journals[0]; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show 6 rows', () => { + expect(de.queryAll(By.css('.row')).length).toEqual(6); + }); + +}); diff --git a/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts new file mode 100644 index 0000000000..cfe42adf7b --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publication-information/publication-information.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +import { Journal } from '../../../../core/submission/models/sherpa-policies-details.model'; + +/** + * This component represents a section that contains the journal publication information. + */ +@Component({ + selector: 'ds-publication-information', + templateUrl: './publication-information.component.html', + styleUrls: ['./publication-information.component.scss'] +}) +export class PublicationInformationComponent { + /** + * Journal to show information from + */ + @Input() journal: Journal; + +} diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html new file mode 100644 index 0000000000..87370cf7e3 --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.html @@ -0,0 +1,16 @@ +
+ + +
+
+

+ {{'submission.sections.sherpa.publisher.policy.more.information' | translate}} +

+ +
+
+
\ No newline at end of file diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.scss b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts new file mode 100644 index 0000000000..3e2c33481a --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PublisherPolicyComponent } from './publisher-policy.component'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { SherpaDataResponse } from '../../../../shared/mocks/section-sherpa-policies.service.mock'; +import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; + +describe('PublisherPolicyComponent', () => { + let component: PublisherPolicyComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [PublisherPolicyComponent], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PublisherPolicyComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.policy = SherpaDataResponse.sherpaResponse.journals[0].policies[0]; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show content accordion', () => { + expect(de.query(By.css('ds-content-accordion'))).toBeTruthy(); + }); + + it('should show 1 row', () => { + expect(de.queryAll(By.css('.row')).length).toEqual(1); + }); +}); diff --git a/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts new file mode 100644 index 0000000000..96ada3904c --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/publisher-policy/publisher-policy.component.ts @@ -0,0 +1,28 @@ +import { Component, Input } from '@angular/core'; + +import { Policy } from '../../../../core/submission/models/sherpa-policies-details.model'; +import { AlertType } from '../../../../shared/alert/aletr-type'; + +/** + * This component represents a section that contains the publisher policy informations. + */ +@Component({ + selector: 'ds-publisher-policy', + templateUrl: './publisher-policy.component.html', + styleUrls: ['./publisher-policy.component.scss'] +}) +export class PublisherPolicyComponent { + + /** + * Policy to show information from + */ + @Input() policy: Policy; + + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + +} diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html new file mode 100644 index 0000000000..4b636ee46e --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.html @@ -0,0 +1,72 @@ + + + +
+ + {{'submission.sections.sherpa.publisher.policy.description' | translate}} + +
+ +
+
+ + + + +
+
+ +
+ + +
+
+
+ +
+
+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+ + + + + +
diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.scss b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts new file mode 100644 index 0000000000..76a980ed3c --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.spec.ts @@ -0,0 +1,117 @@ +import { SharedModule } from '../../../shared/shared.module'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { SherpaDataResponse } from '../../../shared/mocks/section-sherpa-policies.service.mock'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { BrowserModule, By } from '@angular/platform-browser'; + +import { Store } from '@ngrx/store'; +import { AppState } from '../../../app.reducer'; +import { SubmissionSectionSherpaPoliciesComponent } from './section-sherpa-policies.component'; +import { SubmissionService } from '../../submission.service'; +import { DebugElement } from '@angular/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { of as observableOf } from 'rxjs'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('SubmissionSectionSherpaPoliciesComponent', () => { + let component: SubmissionSectionSherpaPoliciesComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const sectionsServiceStub = new SectionsServiceStub(); + + const operationsBuilder = jasmine.createSpyObj('operationsBuilder', { + add: undefined, + remove: undefined, + replace: undefined, + }); + + const storeStub = jasmine.createSpyObj('store', ['dispatch']); + + const sectionData = { + header: 'submit.progressbar.sherpaPolicies', + config: 'http://localhost:8080/server/api/config/submissionaccessoptions/SherpaPoliciesDefaultConfiguration', + mandatory: true, + sectionType: 'sherpaPolicies', + collapsed: false, + enabled: true, + data: SherpaDataResponse, + errorsToShow: [], + serverValidationErrors: [], + isLoading: false, + isValid: true + }; + + describe('SubmissionSectionSherpaPoliciesComponent', () => { + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BrowserModule, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + NgbCollapseModule, + SharedModule + ], + declarations: [SubmissionSectionSherpaPoliciesComponent], + providers: [ + { provide: SectionsService, useValue: sectionsServiceStub }, + { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, + { provide: SubmissionService, useValue: SubmissionServiceStub }, + { provide: Store, useValue: storeStub }, + { provide: 'sectionDataProvider', useValue: sectionData }, + { provide: 'submissionIdProvider', useValue: '1508' }, + ] + }) + .compileComponents(); + }); + + beforeEach(inject([Store], (store: Store) => { + fixture = TestBed.createComponent(SubmissionSectionSherpaPoliciesComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + sectionsServiceStub.getSectionData.and.returnValue(observableOf(SherpaDataResponse)); + fixture.detectChanges(); + })); + + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show refresh button', () => { + expect(de.query(By.css('[data-test="refresh-btn"]'))).toBeTruthy(); + }); + + it('should show publisher information', () => { + expect(de.query(By.css('ds-publication-information'))).toBeTruthy(); + }); + + it('should show publisher policy', () => { + expect(de.query(By.css('ds-publisher-policy'))).toBeTruthy(); + }); + + it('should show metadata information', () => { + expect(de.query(By.css('ds-metadata-information'))).toBeTruthy(); + }); + + it('when refresh button click operationsBuilder.remove should have been called', () => { + de.query(By.css('[data-test="refresh-btn"]')).nativeElement.click(); + expect(operationsBuilder.remove).toHaveBeenCalled(); + }); + + + }); + +}); diff --git a/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts new file mode 100644 index 0000000000..e55b75146f --- /dev/null +++ b/src/app/submission/sections/sherpa-policies/section-sherpa-policies.component.ts @@ -0,0 +1,127 @@ +import { AlertType } from '../../../shared/alert/aletr-type'; +import { Component, Inject } from '@angular/core'; + +import { BehaviorSubject, Observable, of, Subscription } from 'rxjs'; + +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { + WorkspaceitemSectionSherpaPoliciesObject +} from '../../../core/submission/models/workspaceitem-section-sherpa-policies.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SubmissionService } from '../../submission.service'; +import { hasValue, isEmpty } from '../../../shared/empty.util'; + +/** + * This component represents a section for the sherpa policy informations structure. + */ +@Component({ + selector: 'ds-section-sherpa-policies', + templateUrl: './section-sherpa-policies.component.html', + styleUrls: ['./section-sherpa-policies.component.scss'] +}) +@renderSectionFor(SectionsType.SherpaPolicies) +export class SubmissionSectionSherpaPoliciesComponent extends SectionModelComponent { + + /** + * The accesses section data + * @type {WorkspaceitemSectionAccessesObject} + */ + public sherpaPoliciesData$: BehaviorSubject = new BehaviorSubject(null); + + /** + * The [[JsonPatchOperationPathCombiner]] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = false; + + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Initialize instance variables + * + * @param {SectionsService} sectionService + * @param {SectionDataObject} injectedSectionData + * @param {JsonPatchOperationsBuilder} operationsBuilder + * @param {SubmissionService} submissionService + * @param {string} injectedSubmissionId + */ + constructor( + protected sectionService: SectionsService, + protected operationsBuilder: JsonPatchOperationsBuilder, + private submissionService: SubmissionService, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(undefined, injectedSectionData, injectedSubmissionId); + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy() { + + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + + /** + * Initialize all instance variables and retrieve collection default access conditions + */ + protected onSectionInit(): void { + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + this.subs.push( + this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) + .subscribe((sherpaPolicies: WorkspaceitemSectionSherpaPoliciesObject) => { + this.sherpaPoliciesData$.next(sherpaPolicies); + }) + ); + } + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + return of(true); + } + + /** + * Check if section has no data + */ + hasNoData(): boolean { + return isEmpty(this.sherpaPoliciesData$.value); + } + + /** + * Refresh sherpa information + */ + refresh() { + this.operationsBuilder.remove(this.pathCombiner.getPath('retrievalTime')); + this.submissionService.dispatchSaveSection(this.submissionId, this.sectionData.id); + } + +} diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts index aa03d37eb2..d008bf61f1 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts @@ -1,10 +1,9 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, inject, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { - DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormControlEvent, DynamicFormGroupModel, @@ -17,13 +16,13 @@ import { SubmissionService } from '../../../../submission.service'; import { SubmissionSectionUploadFileEditComponent } from './section-upload-file-edit.component'; import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; import { + mockFileFormData, mockSubmissionCollectionId, mockSubmissionId, + mockSubmissionObject, mockUploadConfigResponse, mockUploadConfigResponseMetadata, mockUploadFiles, - mockFileFormData, - mockSubmissionObject, } from '../../../../../shared/mocks/submission.mock'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormComponent } from '../../../../../shared/form/form.component'; @@ -32,12 +31,20 @@ import { getMockFormService } from '../../../../../shared/mocks/form-service.moc import { createTestComponent } from '../../../../../shared/testing/utils.test'; import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { JsonPatchOperationsBuilder } from '../../../../../core/json-patch/builder/json-patch-operations-builder'; -import { SubmissionJsonPatchOperationsServiceStub } from '../../../../../shared/testing/submission-json-patch-operations-service.stub'; -import { SubmissionJsonPatchOperationsService } from '../../../../../core/submission/submission-json-patch-operations.service'; +import { + SubmissionJsonPatchOperationsServiceStub +} from '../../../../../shared/testing/submission-json-patch-operations-service.stub'; +import { + SubmissionJsonPatchOperationsService +} from '../../../../../core/submission/submission-json-patch-operations.service'; import { SectionUploadService } from '../../section-upload.service'; import { getMockSectionUploadService } from '../../../../../shared/mocks/section-upload.service.mock'; -import { FormFieldMetadataValueObject } from '../../../../../shared/form/builder/models/form-field-metadata-value.model'; -import { JsonPatchOperationPathCombiner } from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { + FormFieldMetadataValueObject +} from '../../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { + JsonPatchOperationPathCombiner +} from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { dateToISOFormat } from '../../../../../shared/date.util'; import { of } from 'rxjs'; @@ -171,6 +178,8 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { it('should init form model properly', () => { comp.fileData = fileData; comp.formId = 'testFileForm'; + const maxStartDate = {year: 2022, month: 1, day: 12}; + const maxEndDate = {year: 2019, month: 7, day: 12}; comp.ngOnInit(); @@ -179,6 +188,10 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { expect(comp.formModel[0] instanceof DynamicFormGroupModel).toBeTruthy(); expect(comp.formModel[1] instanceof DynamicFormArrayModel).toBeTruthy(); expect((comp.formModel[1] as DynamicFormArrayModel).groups.length).toBe(2); + const startDateModel = formbuilderService.findById('startDate', comp.formModel); + expect(startDateModel.max).toEqual(maxStartDate); + const endDateModel = formbuilderService.findById('endDate', comp.formModel); + expect(endDateModel.max).toEqual(maxEndDate); }); it('should call setOptions method onChange', () => { @@ -208,20 +221,19 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { const formGroup = formbuilderService.createFormGroup(comp.formModel); const control = formbuilderService.getFormControlById('name', formGroup, comp.formModel, 0); - spyOn(formbuilderService, 'findById').and.callThrough(); + spyOn(control.parent, 'markAsDirty').and.callThrough(); control.value = 'openaccess'; comp.setOptions(model, control); - expect(formbuilderService.findById).not.toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); - expect(formbuilderService.findById).not.toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); + expect(control.parent.markAsDirty).toHaveBeenCalled(); control.value = 'lease'; comp.setOptions(model, control); - expect(formbuilderService.findById).toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); + expect(control.parent.markAsDirty).toHaveBeenCalled(); control.value = 'embargo'; comp.setOptions(model, control); - expect(formbuilderService.findById).toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); + expect(control.parent.markAsDirty).toHaveBeenCalled(); }); it('should retrieve Value From Field properly', () => { diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 3a43e718a0..195d291530 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -3,9 +3,7 @@ import { FormControl } from '@angular/forms'; import { DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER, - DynamicDateControlModel, DynamicDatePickerModel, - DynamicFormArrayGroupModel, DynamicFormArrayModel, DynamicFormControlEvent, DynamicFormControlModel, @@ -15,7 +13,9 @@ import { OR_OPERATOR } from '@ng-dynamic-forms/core'; -import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; +import { + WorkspaceitemSectionUploadFileObject +} from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; import { BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG, @@ -43,12 +43,20 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { filter, mergeMap, take } from 'rxjs/operators'; import { dateToISOFormat } from '../../../../../shared/date.util'; import { SubmissionObject } from '../../../../../core/submission/models/submission-object.model'; -import { WorkspaceitemSectionUploadObject } from '../../../../../core/submission/models/workspaceitem-section-upload.model'; +import { + WorkspaceitemSectionUploadObject +} from '../../../../../core/submission/models/workspaceitem-section-upload.model'; import { JsonPatchOperationsBuilder } from '../../../../../core/json-patch/builder/json-patch-operations-builder'; -import { SubmissionJsonPatchOperationsService } from '../../../../../core/submission/submission-json-patch-operations.service'; -import { JsonPatchOperationPathCombiner } from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { + SubmissionJsonPatchOperationsService +} from '../../../../../core/submission/submission-json-patch-operations.service'; +import { + JsonPatchOperationPathCombiner +} from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { SectionUploadService } from '../../section-upload.service'; import { Subscription } from 'rxjs'; +import { DynamicFormControlCondition } from '@ng-dynamic-forms/core/lib/model/misc/dynamic-form-control-relation.model'; +import { DynamicDateControlValue } from '@ng-dynamic-forms/core/lib/model/dynamic-date-control.model'; /** * This component represents the edit form for bitstream @@ -237,8 +245,6 @@ export class SubmissionSectionUploadFileEditComponent implements OnInit { this.availableAccessConditionOptions.filter((element) => element.name === control.value) .forEach((element) => accessCondition = element ); if (isNotEmpty(accessCondition)) { - const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true; - const startDateControl: FormControl = control.parent.get('startDate') as FormControl; const endDateControl: FormControl = control.parent.get('endDate') as FormControl; @@ -249,33 +255,6 @@ export class SubmissionSectionUploadFileEditComponent implements OnInit { startDateControl?.setValue(null); control.parent.markAsDirty(); endDateControl?.setValue(null); - - if (showGroups) { - if (accessCondition.hasStartDate) { - const startDateModel = this.formBuilderService.findById( - 'startDate', - (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; - - const min = new Date(accessCondition.maxStartDate); - startDateModel.max = { - year: min.getUTCFullYear(), - month: min.getUTCMonth() + 1, - day: min.getUTCDate() - }; - } - if (accessCondition.hasEndDate) { - const endDateModel = this.formBuilderService.findById( - 'endDate', - (model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel; - - const max = new Date(accessCondition.maxEndDate); - endDateModel.max = { - year: max.getUTCFullYear(), - month: max.getUTCMonth() + 1, - day: max.getUTCDate() - }; - } - } } } @@ -335,38 +314,63 @@ export class SubmissionSectionUploadFileEditComponent implements OnInit { } accessConditionTypeModelConfig.options = accessConditionTypeOptions; - // Dynamically assign of relation in config. For startdate, endDate, groups. - const hasStart = []; - const hasEnd = []; - const hasGroups = []; + // Dynamically assign of relation in config. For startDate and endDate. + const startDateCondition: DynamicFormControlCondition[] = []; + const endDateCondition: DynamicFormControlCondition[] = []; + let maxStartDate: DynamicDateControlValue; + let maxEndDate: DynamicDateControlValue; this.availableAccessConditionOptions.forEach((condition) => { - const showStart: boolean = condition.hasStartDate === true; - const showEnd: boolean = condition.hasEndDate === true; - const showGroups: boolean = showStart || showEnd; - if (showStart) { - hasStart.push({id: 'name', value: condition.name}); + + if (condition.hasStartDate) { + startDateCondition.push({ id: 'name', value: condition.name }); + if (condition.maxStartDate) { + const min = new Date(condition.maxStartDate); + maxStartDate = { + year: min.getUTCFullYear(), + month: min.getUTCMonth() + 1, + day: min.getUTCDate() + }; + } } - if (showEnd) { - hasEnd.push({id: 'name', value: condition.name}); - } - if (showGroups) { - hasGroups.push({id: 'name', value: condition.name}); + if (condition.hasEndDate) { + endDateCondition.push({ id: 'name', value: condition.name }); + if (condition.maxEndDate) { + const max = new Date(condition.maxEndDate); + maxEndDate = { + year: max.getUTCFullYear(), + month: max.getUTCMonth() + 1, + day: max.getUTCDate() + }; + } } }); - const confStart = {relations: [{match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart}]}; - const confEnd = {relations: [{match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd}]}; + const confStart = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: startDateCondition }] }; + const confEnd = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: endDateCondition }] }; + const hasStartDate = startDateCondition.length > 0; + const hasEndDate = endDateCondition.length > 0; accessConditionsArrayConfig.groupFactory = () => { const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); const startDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, confStart); + if (maxStartDate) { + startDateConfig.max = maxStartDate; + } + const endDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, confEnd); + if (maxEndDate) { + endDateConfig.max = maxEndDate; + } const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG); accessConditionGroupConfig.group = [type]; - if (hasStart.length > 0) { accessConditionGroupConfig.group.push(startDate); } - if (hasEnd.length > 0) { accessConditionGroupConfig.group.push(endDate); } + if (hasStartDate) { + accessConditionGroupConfig.group.push(startDate); + } + if (hasEndDate) { + accessConditionGroupConfig.group.push(endDate); + } return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)]; }; diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts index 939d1bff29..97ffb7ffcd 100644 --- a/src/app/submission/submission.module.ts +++ b/src/app/submission/submission.module.ts @@ -22,15 +22,27 @@ import { SubmissionSectionLicenseComponent } from './sections/license/section-li import { SubmissionUploadsConfigService } from '../core/config/submission-uploads-config.service'; import { SubmissionEditComponent } from './edit/submission-edit.component'; import { SubmissionSectionUploadFileComponent } from './sections/upload/file/section-upload-file.component'; -import { SubmissionSectionUploadFileEditComponent } from './sections/upload/file/edit/section-upload-file-edit.component'; -import { SubmissionSectionUploadFileViewComponent } from './sections/upload/file/view/section-upload-file-view.component'; -import { SubmissionSectionUploadAccessConditionsComponent } from './sections/upload/accessConditions/submission-section-upload-access-conditions.component'; +import { + SubmissionSectionUploadFileEditComponent +} from './sections/upload/file/edit/section-upload-file-edit.component'; +import { + SubmissionSectionUploadFileViewComponent +} from './sections/upload/file/view/section-upload-file-view.component'; +import { + SubmissionSectionUploadAccessConditionsComponent +} from './sections/upload/accessConditions/submission-section-upload-access-conditions.component'; import { SubmissionSubmitComponent } from './submit/submission-submit.component'; import { storeModuleConfig } from '../app.reducer'; import { SubmissionImportExternalComponent } from './import-external/submission-import-external.component'; -import { SubmissionImportExternalSearchbarComponent } from './import-external/import-external-searchbar/submission-import-external-searchbar.component'; -import { SubmissionImportExternalPreviewComponent } from './import-external/import-external-preview/submission-import-external-preview.component'; -import { SubmissionImportExternalCollectionComponent } from './import-external/import-external-collection/submission-import-external-collection.component'; +import { + SubmissionImportExternalSearchbarComponent +} from './import-external/import-external-searchbar/submission-import-external-searchbar.component'; +import { + SubmissionImportExternalPreviewComponent +} from './import-external/import-external-preview/submission-import-external-preview.component'; +import { + SubmissionImportExternalCollectionComponent +} from './import-external/import-external-collection/submission-import-external-collection.component'; import { SubmissionSectionCcLicensesComponent } from './sections/cc-license/submission-section-cc-licenses.component'; import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; @@ -38,10 +50,19 @@ import { ThemedSubmissionEditComponent } from './edit/themed-submission-edit.com import { ThemedSubmissionSubmitComponent } from './submit/themed-submission-submit.component'; import { ThemedSubmissionImportExternalComponent } from './import-external/themed-submission-import-external.component'; import { FormModule } from '../shared/form/form.module'; -import { NgbAccordionModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbAccordionModule, NgbCollapseModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { SubmissionSectionAccessesComponent } from './sections/accesses/section-accesses.component'; import { SubmissionAccessesConfigService } from '../core/config/submission-accesses-config.service'; import { SectionAccessesService } from './sections/accesses/section-accesses.service'; +import { SubmissionSectionSherpaPoliciesComponent } from './sections/sherpa-policies/section-sherpa-policies.component'; +import { ContentAccordionComponent } from './sections/sherpa-policies/content-accordion/content-accordion.component'; +import { PublisherPolicyComponent } from './sections/sherpa-policies/publisher-policy/publisher-policy.component'; +import { + PublicationInformationComponent +} from './sections/sherpa-policies/publication-information/publication-information.component'; +import { + MetadataInformationComponent +} from './sections/sherpa-policies/metadata-information/metadata-information.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -50,7 +71,7 @@ const ENTRY_COMPONENTS = [ SubmissionSectionLicenseComponent, SubmissionSectionCcLicensesComponent, SubmissionSectionAccessesComponent, - SubmissionSectionUploadFileEditComponent + SubmissionSectionSherpaPoliciesComponent, ]; const DECLARATIONS = [ @@ -75,6 +96,10 @@ const DECLARATIONS = [ SubmissionImportExternalSearchbarComponent, SubmissionImportExternalPreviewComponent, SubmissionImportExternalCollectionComponent, + ContentAccordionComponent, + PublisherPolicyComponent, + PublicationInformationComponent, + MetadataInformationComponent, ]; @NgModule({ @@ -87,8 +112,9 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), FormModule, - NgbAccordionModule, - NgbModalModule + NgbModalModule, + NgbCollapseModule, + NgbAccordionModule ], declarations: DECLARATIONS, exports: DECLARATIONS, @@ -97,7 +123,7 @@ const DECLARATIONS = [ SectionsService, SubmissionUploadsConfigService, SubmissionAccessesConfigService, - SectionAccessesService + SectionAccessesService, ] }) diff --git a/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.html b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.html new file mode 100644 index 0000000000..d7f6129b3b --- /dev/null +++ b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.html @@ -0,0 +1,207 @@ +
+
+
+

{{'notifications.events.title'| translate}}

+

{{'quality-assurance.events.description'| translate}}

+

+ + + {{'quality-assurance.events.back' | translate}} + +

+
+
+
+
+

+ {{'quality-assurance.events.topic' | translate}} {{this.showTopic}} +

+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
{{'quality-assurance.event.table.trust' | translate}}{{'quality-assurance.event.table.publication' | translate}}{{'quality-assurance.event.table.details' | translate}}{{'quality-assurance.event.table.project-details' | translate}}{{'quality-assurance.event.table.actions' | translate}}
{{eventElement?.event?.trust}} + {{eventElement.title}} + {{eventElement.title}} + +

{{'quality-assurance.event.table.pidtype' | translate}} {{eventElement.event.message.type}}

+

{{'quality-assurance.event.table.pidvalue' | translate}}
+ + {{eventElement.event.message.value}} + + {{eventElement.event.message.value}} +

+
+

{{'quality-assurance.event.table.subjectValue' | translate}}
{{eventElement.event.message.value}}

+
+

+ {{'quality-assurance.event.table.abstract' | translate}}
+ {{eventElement.event.message.abstract}} +

+ +
+

+ {{'quality-assurance.event.table.suggestedProject' | translate}} +

+

+ {{'quality-assurance.event.table.project' | translate}}
+ {{eventElement.event.message.title}} +

+

+ {{'quality-assurance.event.table.acronym' | translate}} {{eventElement.event.message.acronym}}
+ {{'quality-assurance.event.table.code' | translate}} {{eventElement.event.message.code}}
+ {{'quality-assurance.event.table.funder' | translate}} {{eventElement.event.message.funder}}
+ {{'quality-assurance.event.table.fundingProgram' | translate}} {{eventElement.event.message.fundingProgram}}
+ {{'quality-assurance.event.table.jurisdiction' | translate}} {{eventElement.event.message.jurisdiction}} +

+
+
+ {{(eventElement.hasProject ? 'quality-assurance.event.project.found' : 'quality-assurance.event.project.notFound') | translate}} + {{eventElement.handle}} +
+ + +
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + diff --git a/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.spec.ts b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.spec.ts new file mode 100644 index 0000000000..41358b20a5 --- /dev/null +++ b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.spec.ts @@ -0,0 +1,332 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceEventRestService } from '../../../core/suggestion-notifications/qa/events/quality-assurance-event-rest.service'; +import { QualityAssuranceEventsComponent } from './quality-assurance-events.component'; +import { + getMockQualityAssuranceEventRestService, + ItemMockPid10, + ItemMockPid8, + ItemMockPid9, + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound, + NotificationsMockDspaceObject +} from '../../../shared/mocks/notifications.mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { QualityAssuranceEventObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceEventData } from '../project-entry-import-modal/project-entry-import-modal.component'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +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('QualityAssuranceEventsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceEventsComponent; + let compAsAny: any; + let scheduler: TestScheduler; + + const modalStub = { + open: () => ( {result: new Promise((res, rej) => 'do')} ), + close: () => null, + dismiss: () => null + }; + const qualityAssuranceEventRestServiceStub: any = getMockQualityAssuranceEventRestService(); + const activatedRouteParams = { + qualityAssuranceEventsParams: { + currentPage: 0, + pageSize: 10 + } + }; + const activatedRouteParamsMap = { + id: 'ENRICH!MISSING!PROJECT' + }; + + const events: QualityAssuranceEventObject[] = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound + ]; + const paginationService = new PaginationServiceStub(); + + function getQualityAssuranceEventData1(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false, + target: ItemMockPid8 + }; + } + + function getQualityAssuranceEventData2(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectNotFound, + id: qualityAssuranceEventObjectMissingProjectNotFound.id, + title: qualityAssuranceEventObjectMissingProjectNotFound.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: ItemMockPid9 + }; + } + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceEventsComponent, + TestComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub(activatedRouteParamsMap, activatedRouteParams) }, + { provide: QualityAssuranceEventRestService, useValue: qualityAssuranceEventRestServiceStub }, + { provide: NgbModal, useValue: modalStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceEventsComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + scheduler = getTestScheduler(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceEventsComponent', inject([QualityAssuranceEventsComponent], (app: QualityAssuranceEventsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceEventsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + describe('setEventUpdated', () => { + it('should update events', () => { + const expected = [ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ]; + scheduler.schedule(() => { + compAsAny.setEventUpdated(events); + }); + scheduler.flush(); + + expect(comp.eventsUpdated$.value).toEqual(expected); + }); + }); + + describe('modalChoice', () => { + beforeEach(() => { + spyOn(comp, 'executeAction'); + spyOn(comp, 'openModal'); + }); + + it('should call executeAction if a project is present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData1(), modalStub); + expect(comp.executeAction).toHaveBeenCalledWith(action, getQualityAssuranceEventData1()); + }); + + it('should call openModal if a project is not present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData2(), modalStub); + expect(comp.openModal).toHaveBeenCalledWith(action, getQualityAssuranceEventData2(), modalStub); + }); + }); + + describe('openModal', () => { + it('should call modalService.open', () => { + const action = 'ACCEPTED'; + comp.selectedReason = null; + spyOn(compAsAny.modalService, 'open').and.returnValue({ result: new Promise((res, rej) => 'do' ) }); + spyOn(comp, 'executeAction'); + + comp.openModal(action, getQualityAssuranceEventData1(), modalStub); + expect(compAsAny.modalService.open).toHaveBeenCalled(); + }); + }); + + describe('openModalLookup', () => { + it('should call modalService.open', () => { + spyOn(comp, 'boundProject'); + spyOn(compAsAny.modalService, 'open').and.returnValue( + { + componentInstance: { + externalSourceEntry: null, + label: null, + importedObject: observableOf({ + indexableObject: NotificationsMockDspaceObject + }) + } + } + ); + scheduler.schedule(() => { + comp.openModalLookup(getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.modalService.open).toHaveBeenCalled(); + expect(compAsAny.boundProject).toHaveBeenCalled(); + }); + }); + + describe('executeAction', () => { + it('should call getQualityAssuranceEvents on 200 response from REST', () => { + const action = 'ACCEPTED'; + spyOn(compAsAny, 'getQualityAssuranceEvents'); + qualityAssuranceEventRestServiceStub.patchEvent.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.executeAction(action, getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.getQualityAssuranceEvents).toHaveBeenCalled(); + }); + }); + + describe('boundProject', () => { + it('should populate the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData2(); + const projectId = 'UUID-23943-34u43-38344'; + const projectName = 'Test Project'; + const projectHandle = '1000/1000'; + qualityAssuranceEventRestServiceStub.boundProject.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.boundProject(eventData, projectId, projectName, projectHandle); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(true); + expect(eventData.projectId).toEqual(projectId); + expect(eventData.projectTitle).toEqual(projectName); + expect(eventData.handle).toEqual(projectHandle); + }); + }); + + describe('removeProject', () => { + it('should remove the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData1(); + qualityAssuranceEventRestServiceStub.removeProject.and.returnValue(createNoContentRemoteDataObject$()); + + scheduler.schedule(() => { + comp.removeProject(eventData); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(false); + expect(eventData.projectId).toBeNull(); + expect(eventData.projectTitle).toBeNull(); + expect(eventData.handle).toBeNull(); + }); + }); + + describe('getQualityAssuranceEvents', () => { + it('should call the "qualityAssuranceEventRestService.getEventsByTopic" to take data and "setEventUpdated" to populate eventData', () => { + comp.paginationConfig = new PaginationComponentOptions(); + comp.paginationConfig.currentPage = 1; + comp.paginationConfig.pageSize = 20; + comp.paginationSortConfig = new SortOptions('trust', SortDirection.DESC); + comp.topic = activatedRouteParamsMap.id; + const options: FindListOptions = Object.assign(new FindListOptions(), { + currentPage: comp.paginationConfig.currentPage, + elementsPerPage: comp.paginationConfig.pageSize + }); + + const pageInfo = new PageInfo({ + elementsPerPage: comp.paginationConfig.pageSize, + totalElements: 0, + totalPages: 1, + currentPage: comp.paginationConfig.currentPage + }); + const array = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound, + ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + qualityAssuranceEventRestServiceStub.getEventsByTopic.and.returnValue(observableOf(paginatedListRD)); + spyOn(compAsAny, 'setEventUpdated'); + + scheduler.schedule(() => { + compAsAny.getQualityAssuranceEvents(); + }); + scheduler.flush(); + + expect(compAsAny.qualityAssuranceEventRestService.getEventsByTopic).toHaveBeenCalledWith( + activatedRouteParamsMap.id, + options, + followLink('target'),followLink('related') + ); + expect(compAsAny.setEventUpdated).toHaveBeenCalled(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.ts b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.ts new file mode 100644 index 0000000000..edac869f8e --- /dev/null +++ b/src/app/suggestion-notifications/qa/events/quality-assurance-events.component.ts @@ -0,0 +1,464 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, from, Observable, of as observableOf, Subscription } from 'rxjs'; +import { distinctUntilChanged, map, mergeMap, scan, switchMap, take } from 'rxjs/operators'; + +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { + QualityAssuranceEventObject, + OpenaireQualityAssuranceEventMessageObject +} from '../../../core/suggestion-notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceEventRestService } from '../../../core/suggestion-notifications/qa/events/quality-assurance-event-rest.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { hasValue } from '../../../shared/empty.util'; +import { ItemSearchResult } from '../../../shared/object-collection/shared/item-search-result.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + QualityAssuranceEventData, + ProjectEntryImportModalComponent +} from '../project-entry-import-modal/project-entry-import-modal.component'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { combineLatest } from 'rxjs/internal/observable/combineLatest'; +import { Item } from '../../../core/shared/item.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; + +/** + * Component to display the Quality Assurance event list. + */ +@Component({ + selector: 'ds-quality-assurance-events', + templateUrl: './quality-assurance-events.component.html', + styleUrls: ['./quality-assurance-events.scomponent.scss'], +}) +export class QualityAssuranceEventsComponent implements OnInit { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'bep', + currentPage: 1, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance event list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions = new SortOptions('trust', SortDirection.DESC); + /** + * Array to save the presence of a project inside an Quality Assurance event. + * @type {QualityAssuranceEventData[]>} + */ + public eventsUpdated$: BehaviorSubject = new BehaviorSubject([]); + /** + * The total number of Quality Assurance events. + * @type {Observable} + */ + public totalElements$: Observable; + /** + * The topic of the Quality Assurance events; suitable for displaying. + * @type {string} + */ + public showTopic: string; + /** + * The topic of the Quality Assurance events; suitable for HTTP calls. + * @type {string} + */ + public topic: string; + /** + * The rejected/ignore reason. + * @type {string} + */ + public selectedReason: string; + /** + * Contains the information about the loading status of the page. + * @type {Observable} + */ + public isEventPageLoading: BehaviorSubject = new BehaviorSubject(false); + /** + * Contains the information about the loading status of the events inside the pagination component. + * @type {Observable} + */ + public isEventLoading: BehaviorSubject = new BehaviorSubject(false); + /** + * The modal reference. + * @type {any} + */ + public modalRef: any; + /** + * Used to store the status of the 'Show more' button of the abstracts. + * @type {boolean} + */ + public showMore = false; + /** + * The FindListOptions object + */ + protected defaultConfig: FindListOptions = Object.assign(new FindListOptions(), {sort: this.paginationSortConfig}); + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {ActivatedRoute} activatedRoute + * @param {NgbModal} modalService + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceEventRestService} qualityAssuranceEventRestService + * @param {PaginationService} paginationService + * @param {TranslateService} translateService + */ + constructor( + private activatedRoute: ActivatedRoute, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private qualityAssuranceEventRestService: QualityAssuranceEventRestService, + private paginationService: PaginationService, + private translateService: TranslateService + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.isEventPageLoading.next(true); + + this.activatedRoute.paramMap.pipe( + map((params) => params.get('topicId')), + take(1) + ).subscribe((id: string) => { + const regEx = /!/g; + this.showTopic = id.replace(regEx, '/'); + this.topic = id; + this.isEventPageLoading.next(false); + this.getQualityAssuranceEvents(); + }); + } + + /** + * Check if table have a detail column + */ + public hasDetailColumn(): boolean { + return (this.showTopic.indexOf('/PROJECT') !== -1 || + this.showTopic.indexOf('/PID') !== -1 || + this.showTopic.indexOf('/SUBJECT') !== -1 || + this.showTopic.indexOf('/ABSTRACT') !== -1 + ); + } + + /** + * Open a modal or run the executeAction directly based on the presence of the project. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public modalChoice(action: string, eventData: QualityAssuranceEventData, content: any): void { + if (eventData.hasProject) { + this.executeAction(action, eventData); + } else { + this.openModal(action, eventData, content); + } + } + + /** + * Open the selected modal and performs the action if needed. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public openModal(action: string, eventData: QualityAssuranceEventData, content: any): void { + this.modalService.open(content, { ariaLabelledBy: 'modal-basic-title' }).result.then( + (result) => { + if (result === 'do') { + eventData.reason = this.selectedReason; + this.executeAction(action, eventData); + } + this.selectedReason = null; + }, + (_reason) => { + this.selectedReason = null; + } + ); + } + + /** + * Open a modal where the user can select the project. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + */ + public openModalLookup(eventData: QualityAssuranceEventData): void { + this.modalRef = this.modalService.open(ProjectEntryImportModalComponent, { + size: 'lg' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.externalSourceEntry = eventData; + modalComp.label = 'project'; + this.subs.push( + modalComp.importedObject.pipe(take(1)) + .subscribe((object: ItemSearchResult) => { + const projectTitle = Metadata.first(object.indexableObject.metadata, 'dc.title'); + this.boundProject( + eventData, + object.indexableObject.id, + projectTitle.value, + object.indexableObject.handle + ); + }) + ); + } + + /** + * Performs the choosen action calling the REST service. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public executeAction(action: string, eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.patchEvent(action, eventData.event, eventData.reason).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.isSuccess && rd.statusCode === 200) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.action.saved') + ); + this.getQualityAssuranceEvents(); + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.action.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Bound a project to the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + * @param {string} projectId + * the project Id to bound + * @param {string} projectTitle + * the project title + * @param {string} projectHandle + * the project handle + */ + public boundProject(eventData: QualityAssuranceEventData, projectId: string, projectTitle: string, projectHandle: string): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.boundProject(eventData.id, projectId).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.isSuccess) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.bounded') + ); + eventData.hasProject = true; + eventData.projectTitle = projectTitle; + eventData.handle = projectHandle; + eventData.projectId = projectId; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Remove the bounded project from the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public removeProject(eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.removeProject(eventData.id).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.isSuccess) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.removed') + ); + eventData.hasProject = false; + eventData.projectTitle = null; + eventData.handle = null; + eventData.projectId = null; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Check if the event has a valid href. + * @param event + */ + public hasPIDHref(event: OpenaireQualityAssuranceEventMessageObject): boolean { + return this.getPIDHref(event) !== null; + } + + /** + * Get the event pid href. + * @param event + */ + public getPIDHref(event: OpenaireQualityAssuranceEventMessageObject): string { + return this.computePIDHref(event); + } + + + /** + * Dispatch the Quality Assurance events retrival. + */ + public getQualityAssuranceEvents(): void { + this.paginationService.getFindListOptions(this.paginationConfig.id, this.defaultConfig).pipe( + distinctUntilChanged(), + switchMap((options: FindListOptions) => this.qualityAssuranceEventRestService.getEventsByTopic( + this.topic, + options, + followLink('target'), followLink('related') + )), + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData>) => { + if (rd.hasSucceeded) { + this.isEventLoading.next(false); + this.totalElements$ = observableOf(rd.payload.totalElements); + this.setEventUpdated(rd.payload.page); + } else { + throw new Error('Can\'t retrieve Quality Assurance events from the Broker events REST service'); + } + this.qualityAssuranceEventRestService.clearFindByTopicRequests(); + }); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + + /** + * Set the project status for the Quality Assurance events. + * + * @param {QualityAssuranceEventObject[]} events + * the Quality Assurance event item + */ + protected setEventUpdated(events: QualityAssuranceEventObject[]): void { + this.subs.push( + from(events).pipe( + mergeMap((event: QualityAssuranceEventObject) => { + const related$ = event.related.pipe( + getFirstCompletedRemoteData(), + ); + const target$ = event.target.pipe( + getFirstCompletedRemoteData() + ); + return combineLatest([related$, target$]).pipe( + map(([relatedItemRD, targetItemRD]: [RemoteData, RemoteData]) => { + const data: QualityAssuranceEventData = { + event: event, + id: event.id, + title: event.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: (targetItemRD?.hasSucceeded) ? targetItemRD.payload : null, + }; + if (relatedItemRD?.hasSucceeded && relatedItemRD?.payload?.id) { + data.hasProject = true; + data.projectTitle = event.message.title; + data.projectId = relatedItemRD?.payload?.id; + data.handle = relatedItemRD?.payload?.handle; + } + return data; + }) + ); + }), + scan((acc: any, value: any) => [...acc, value], []), + take(events.length) + ).subscribe( + (eventsReduced) => { + this.eventsUpdated$.next(eventsReduced); + } + ) + ); + } + + protected computePIDHref(event: OpenaireQualityAssuranceEventMessageObject) { + const type = event.type.toLowerCase(); + const pid = event.value; + let prefix = null; + switch (type) { + case 'arxiv': { + prefix = 'https://arxiv.org/abs/'; + break; + } + case 'handle': { + prefix = 'https://hdl.handle.net/'; + break; + } + case 'urn': { + prefix = ''; + break; + } + case 'doi': { + prefix = 'https://doi.org/'; + break; + } + case 'pmc': { + prefix = 'https://www.ncbi.nlm.nih.gov/pmc/articles/'; + break; + } + case 'pmid': { + prefix = 'https://pubmed.ncbi.nlm.nih.gov/'; + break; + } + case 'ncid': { + prefix = 'https://ci.nii.ac.jp/ncid/'; + break; + } + default: { + break; + } + } + if (prefix === null) { + return null; + } + return prefix + pid; + } +} diff --git a/src/app/suggestion-notifications/qa/events/quality-assurance-events.scomponent.scss b/src/app/suggestion-notifications/qa/events/quality-assurance-events.scomponent.scss new file mode 100644 index 0000000000..b38da70f37 --- /dev/null +++ b/src/app/suggestion-notifications/qa/events/quality-assurance-events.scomponent.scss @@ -0,0 +1,21 @@ +.button-rows { + min-width: 200px; +} + +.button-width { + width: 100%; +} + +.abstract-container { + height: 76px; + overflow: hidden; +} + +.text-ellipsis { + text-overflow: ellipsis; +} + +.show { + overflow: visible; + height: auto; +} diff --git a/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html new file mode 100644 index 0000000000..1090fd22fc --- /dev/null +++ b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html @@ -0,0 +1,70 @@ + + + diff --git a/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss new file mode 100644 index 0000000000..7db9839e38 --- /dev/null +++ b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts new file mode 100644 index 0000000000..42a57c2ac5 --- /dev/null +++ b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts @@ -0,0 +1,210 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { Item } from '../../../core/shared/item.model'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ImportType, ProjectEntryImportModalComponent } from './project-entry-import-modal.component'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockSearchService } from '../../../shared/mocks/search-service.mock'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + ItemMockPid10, + qualityAssuranceEventObjectMissingProjectFound, + NotificationsMockDspaceObject +} from '../../../shared/mocks/notifications.mock'; + +const eventData = { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false +}; + +const searchString = 'Test project to search'; +const pagination = Object.assign( + new PaginationComponentOptions(), { + id: 'notifications-project-bound', + pageSize: 3 + } +); +const searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: 'funding', + query: searchString, + pagination: pagination + } +)); +const pageInfo = new PageInfo({ + elementsPerPage: 3, + totalElements: 1, + totalPages: 1, + currentPage: 1 +}); +const array = [ + NotificationsMockDspaceObject, +]; +const paginatedList = buildPaginatedList(pageInfo, array); +const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + +describe('ProjectEntryImportModalComponent test suite', () => { + let fixture: ComponentFixture; + let comp: ProjectEntryImportModalComponent; + let compAsAny: any; + + const modalStub = jasmine.createSpyObj('modal', ['close', 'dismiss']); + const uuid = '123e4567-e89b-12d3-a456-426614174003'; + const searchServiceStub: any = getMockSearchService(); + + + beforeEach(async (() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + ProjectEntryImportModalComponent, + TestComponent, + ], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SelectableListService, useValue: jasmine.createSpyObj('selectableListService', ['deselect', 'select', 'deselectAll']) }, + ProjectEntryImportModalComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + searchServiceStub.search.and.returnValue(observableOf(paginatedListRD)); + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ProjectEntryImportModalComponent', inject([ProjectEntryImportModalComponent], (app: ProjectEntryImportModalComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ProjectEntryImportModalComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + describe('close', () => { + it('should close the modal', () => { + comp.close(); + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('search', () => { + it('should call SearchService.search', () => { + + (searchServiceStub as any).search.and.returnValue(observableOf(paginatedListRD)); + comp.pagination = pagination; + + comp.search(searchString); + expect(comp.searchService.search).toHaveBeenCalledWith(searchOptions); + }); + }); + + describe('bound', () => { + it('should call close, deselectAllLists and importedObject.emit', () => { + spyOn(comp, 'deselectAllLists'); + spyOn(comp, 'close'); + spyOn(comp.importedObject, 'emit'); + comp.selectedEntity = NotificationsMockDspaceObject; + comp.bound(); + + expect(comp.importedObject.emit).toHaveBeenCalled(); + expect(comp.deselectAllLists).toHaveBeenCalled(); + expect(comp.close).toHaveBeenCalled(); + }); + }); + + describe('selectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectEntity(entity); + }); + + it('should set selected entity', () => { + expect(comp.selectedEntity).toBe(entity); + }); + + it('should set the import type to local entity', () => { + expect(comp.selectedImportType).toEqual(ImportType.LocalEntity); + }); + }); + + describe('deselectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectedImportType = ImportType.LocalEntity; + comp.selectedEntity = entity; + comp.deselectEntity(); + }); + + it('should remove the selected entity', () => { + expect(comp.selectedEntity).toBeUndefined(); + }); + + it('should set the import type to none', () => { + expect(comp.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('deselectAllLists', () => { + it('should call SelectableListService.deselectAll', () => { + comp.deselectAllLists(); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.entityListId); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.authorityListId); + }); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + eventData = eventData; +} diff --git a/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts new file mode 100644 index 0000000000..bde97f364c --- /dev/null +++ b/src/app/suggestion-notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts @@ -0,0 +1,279 @@ +import { Component, EventEmitter, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { CollectionElementLinkType } from '../../../shared/object-collection/collection-element-link.type'; +import { Context } from '../../../core/shared/context.model'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { + QualityAssuranceEventObject, + QualityAssuranceEventMessageObject, + OpenaireQualityAssuranceEventMessageObject, +} from '../../../core/suggestion-notifications/qa/models/quality-assurance-event.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Item } from '../../../core/shared/item.model'; + +/** + * The possible types of import for the external entry + */ +export enum ImportType { + None = 'None', + LocalEntity = 'LocalEntity', + LocalAuthority = 'LocalAuthority', + NewEntity = 'NewEntity', + NewAuthority = 'NewAuthority' +} + +/** + * The data type passed from the parent page + */ +export interface QualityAssuranceEventData { + /** + * The Quality Assurance event + */ + event: QualityAssuranceEventObject; + /** + * The Quality Assurance event Id (uuid) + */ + id: string; + /** + * The publication title + */ + title: string; + /** + * Contains the boolean that indicates if a project is present + */ + hasProject: boolean; + /** + * The project title, if present + */ + projectTitle: string; + /** + * The project id (uuid), if present + */ + projectId: string; + /** + * The project handle, if present + */ + handle: string; + /** + * The reject/discard reason + */ + reason: string; + /** + * Contains the boolean that indicates if there is a running operation (REST call) + */ + isRunning: boolean; + /** + * The related publication DSpace item + */ + target?: Item; +} + +@Component({ + selector: 'ds-project-entry-import-modal', + styleUrls: ['./project-entry-import-modal.component.scss'], + templateUrl: './project-entry-import-modal.component.html' +}) +/** + * Component to display a modal window for linking a project to an Quality Assurance event + * Shows information about the selected project and a selectable list. + */ +export class ProjectEntryImportModalComponent implements OnInit { + /** + * The external source entry + */ + @Input() externalSourceEntry: QualityAssuranceEventData; + /** + * The number of results per page + */ + pageSize = 3; + /** + * The prefix for every i18n key within this modal + */ + labelPrefix = 'quality-assurance.event.modal.'; + /** + * The search configuration to retrieve project + */ + configuration = 'funding'; + /** + * The label to use for all messages (added to the end of relevant i18n keys) + */ + label: string; + /** + * The project title from the parent object + */ + projectTitle: string; + /** + * The search results + */ + localEntitiesRD$: Observable>>>; + /** + * Information about the data loading status + */ + isLoading$ = observableOf(true); + /** + * Search options to use for fetching projects + */ + searchOptions: PaginatedSearchOptions; + /** + * The context we're currently in (submission) + */ + context = Context.EntitySearchModalWithNameVariants; + /** + * List ID for selecting local entities + */ + entityListId = 'notifications-project-bound'; + /** + * List ID for selecting local authorities + */ + authorityListId = 'notifications-project-bound-authority'; + /** + * ImportType enum + */ + importType = ImportType; + /** + * The type of link to render in listable elements + */ + linkTypes = CollectionElementLinkType; + /** + * The type of import the user currently has selected + */ + selectedImportType = ImportType.None; + /** + * The selected local entity + */ + selectedEntity: ListableObject; + /** + * An project has been selected, send it to the parent component + */ + importedObject: EventEmitter = new EventEmitter(); + /** + * Pagination options + */ + pagination: PaginationComponentOptions; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {NgbActiveModal} modal + * @param {SearchService} searchService + * @param {SelectableListService} selectService + */ + constructor(public modal: NgbActiveModal, + public searchService: SearchService, + private selectService: SelectableListService) { } + + /** + * Component intitialization. + */ + public ngOnInit(): void { + this.pagination = Object.assign(new PaginationComponentOptions(), { id: 'notifications-project-bound', pageSize: this.pageSize }); + this.projectTitle = (this.externalSourceEntry.projectTitle !== null) ? this.externalSourceEntry.projectTitle + : (this.externalSourceEntry.event.message as OpenaireQualityAssuranceEventMessageObject).title; + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: this.projectTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + + /** + * Close the modal. + */ + public close(): void { + this.deselectAllLists(); + this.modal.close(); + } + + /** + * Perform a project search by title. + */ + public search(searchTitle): void { + if (isNotEmpty(searchTitle)) { + const filterRegEx = /[:]/g; + this.isLoading$ = observableOf(true); + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: (searchTitle) ? searchTitle.replace(filterRegEx, '') : searchTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + } + + /** + * Perform the bound of the project. + */ + public bound(): void { + if (this.selectedEntity !== undefined) { + this.importedObject.emit(this.selectedEntity); + } + this.selectedImportType = ImportType.None; + this.deselectAllLists(); + this.close(); + } + + /** + * Deselected a local entity + */ + public deselectEntity(): void { + this.selectedEntity = undefined; + if (this.selectedImportType === ImportType.LocalEntity) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local entity + * @param entity + */ + public selectEntity(entity): void { + this.selectedEntity = entity; + this.selectedImportType = ImportType.LocalEntity; + } + + /** + * Deselect every element from both entity and authority lists + */ + public deselectAllLists(): void { + this.selectService.deselectAll(this.entityListId); + this.selectService.deselectAll(this.authorityListId); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.deselectAllLists(); + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.actions.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.actions.ts new file mode 100644 index 0000000000..06db9dda06 --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceSourceActionTypes = { + ADD_SOURCE: type('dspace/integration/suggestion-notifications/qa/ADD_SOURCE'), + RETRIEVE_ALL_SOURCE: type('dspace/integration/suggestion-notifications/qa/RETRIEVE_ALL_SOURCE'), + RETRIEVE_ALL_SOURCE_ERROR: type('dspace/integration/suggestion-notifications/qa/RETRIEVE_ALL_SOURCE_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance source. + */ +export class RetrieveAllSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE; + payload: { + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveAllSourceAction. + * + * @param elementsPerPage + * the number of source per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number) { + this.payload = { + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance source' error. + */ +export class RetrieveAllSourceErrorAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance source objects. + * Called by the ??? effect. + */ +export class AddSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.ADD_SOURCE; + payload: { + source: QualityAssuranceSourceObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddSourceAction. + * + * @param source + * the list of source + * @param totalPages + * the total available pages of source + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance source + */ + constructor(source: QualityAssuranceSourceObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + source, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceSourceActions + = RetrieveAllSourceAction + |RetrieveAllSourceErrorAction + |AddSourceAction; diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.html b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.html new file mode 100644 index 0000000000..20f4d4394a --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.html @@ -0,0 +1,58 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+

{{'quality-assurance.source.description'| translate}}

+
+
+
+
+

{{'quality-assurance.source'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.source' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{sourceElement.id}}{{sourceElement.lastEvent}} +
+ +
+
+
+
+
+
+
+
+ diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.scss b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.spec.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.spec.ts new file mode 100644 index 0000000000..2f588125b2 --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.spec.ts @@ -0,0 +1,152 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceSourceComponent } from './quality-assurance-source.component'; +import { SuggestionNotificationsStateService } from '../../suggestion-notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; + +describe('QualityAssuranceSourceComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceSourceComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceSourceParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceSourceComponent, + TestComponent, + ], + providers: [ + { provide: SuggestionNotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), params: observableOf({}) } }, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceSourceComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceSource.and.returnValue(observableOf([ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceSourceTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceSourceCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceSourceTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceSourceLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceSourceLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceSourceProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceSourceComponent', inject([QualityAssuranceSourceComponent], (app: QualityAssuranceSourceComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two Source', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceSourceComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.sources$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceSource'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceSource).toHaveBeenCalled(); + }); + + it(('isSourceLoading should return FALSE'), () => { + expect(comp.isSourceLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isSourceProcessing should return FALSE'), () => { + expect(comp.isSourceProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceSource should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.ts new file mode 100644 index 0000000000..372dc654ff --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.component.ts @@ -0,0 +1,139 @@ +import { Component, OnInit } from '@angular/core'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, take } from 'rxjs/operators'; +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SuggestionNotificationsStateService } from '../../suggestion-notifications-state.service'; +import { AdminQualityAssuranceSourcePageParams } from '../../../admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { hasValue } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-quality-assurance-source', + templateUrl: './quality-assurance-source.component.html', + styleUrls: ['./quality-assurance-source.component.scss'] +}) +export class QualityAssuranceSourceComponent implements OnInit { + + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance source list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance source list. + */ + public sources$: Observable; + /** + * The total number of Quality Assurance sources. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {SuggestionNotificationsStateService} notificationsStateService + */ + constructor( + private paginationService: PaginationService, + private notificationsStateService: SuggestionNotificationsStateService, + ) { } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.sources$ = this.notificationsStateService.getQualityAssuranceSource(); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceSourceTotals(); + } + + /** + * First Quality Assurance source loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceSourceLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceSource(); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if the source are loading, 'false' otherwise. + */ + public isSourceLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the source (ex.: a REST call), 'false' otherwise. + */ + public isSourceProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceProcessing(); + } + + /** + * Dispatch the Quality Assurance source retrival. + */ + public getQualityAssuranceSource(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceSource( + options.pageSize, + options.currentPage + ); + }); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: AdminQualityAssuranceSourcePageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.effects.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.effects.ts new file mode 100644 index 0000000000..2d758d2625 --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.effects.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; +import { + AddSourceAction, + QualityAssuranceSourceActionTypes, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction, +} from './quality-assurance-source.actions'; + +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { QualityAssuranceSourceRestService } from '../../../core/suggestion-notifications/qa/source/quality-assurance-source-rest.service'; + +/** + * Provides effect methods for the Quality Assurance source actions. + */ +@Injectable() +export class QualityAssuranceSourceEffects { + + /** + * Retrieve all Quality Assurance source managing pagination and errors. + */ + @Effect() retrieveAllSource$ = this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllSourceAction, any]) => { + return this.qualityAssuranceSourceService.getSources( + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((sources: PaginatedList) => + new AddSourceAction(sources.page, sources.totalPages, sources.currentPage, sources.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllSourceErrorAction()); + }) + ); + }) + ); + + /** + * Show a notification on error. + */ + @Effect({ dispatch: false }) retrieveAllSourceErrorAction$ = this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.source.error.service.retrieve')); + }) + ); + + /** + * Clear find all source requests from cache. + */ + @Effect({ dispatch: false }) addSourceAction$ = this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.ADD_SOURCE), + tap(() => { + this.qualityAssuranceSourceDataService.clearFindAllSourceRequests(); + }) + ); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + * @param {QualityAssuranceSourceRestService} qualityAssuranceSourceDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private qualityAssuranceSourceDataService: QualityAssuranceSourceRestService + ) { } +} diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.spec.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.spec.ts new file mode 100644 index 0000000000..fcb717067d --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddSourceAction, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction + } from './quality-assurance-source.actions'; + import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './quality-assurance-source.reducer'; + import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid + } from '../../../shared/mocks/notifications.mock'; + + describe('qualityAssuranceSourceReducer test suite', () => { + let qualityAssuranceSourceInitialState: QualityAssuranceSourceState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceSourceInitialState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_SOURCE should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = true; + + const action = new RetrieveAllSourceAction(elementPerPage, currentPage); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_SOURCE_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllSourceErrorAction(); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_SOURCE should populate the State with Quality Assurance source', () => { + const expectedState = { + source: [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddSourceAction( + [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + }); diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.ts new file mode 100644 index 0000000000..d83a0e4341 --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceActionTypes, QualityAssuranceSourceActions } from './quality-assurance-source.actions'; + +/** + * The interface representing the Quality Assurance source state. + */ +export interface QualityAssuranceSourceState { + source: QualityAssuranceSourceObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance source state initialization. + */ +const qualityAssuranceSourceInitialState: QualityAssuranceSourceState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Source Reducer + * + * @param state + * the current state initialized with qualityAssuranceSourceInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceSourceState + * the new state + */ +export function qualityAssuranceSourceReducer(state = qualityAssuranceSourceInitialState, action: QualityAssuranceSourceActions): QualityAssuranceSourceState { + switch (action.type) { + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE: { + return Object.assign({}, state, { + source: [], + processing: true + }); + } + + case QualityAssuranceSourceActionTypes.ADD_SOURCE: { + return Object.assign({}, state, { + source: action.payload.source, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.spec.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.spec.ts new file mode 100644 index 0000000000..208e45e387 --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceSourceRestService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceRestService } from '../../../core/suggestion-notifications/qa/source/quality-assurance-source-rest.service'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceSourceService', () => { + let service: QualityAssuranceSourceService; + let restService: QualityAssuranceSourceRestService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceSourceRestService, useClass: getMockQualityAssuranceSourceRestService }, + { provide: QualityAssuranceSourceService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.get(QualityAssuranceSourceRestService); + restServiceAsAny = restService; + restServiceAsAny.getSources.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceSourceService(restService); + serviceAsAny = service; + }); + + describe('getSources', () => { + it('Should proxy the call to qualityAssuranceSourceRestService.getSources', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + const result = service.getSources(elementsPerPage, currentPage); + expect((service as any).qualityAssuranceSourceRestService.getSources).toHaveBeenCalledWith(findListOptions); + }); + + it('Should return a paginated list of Quality Assurance Source', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getSources(elementsPerPage, currentPage); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.ts b/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.ts new file mode 100644 index 0000000000..2d413a906d --- /dev/null +++ b/src/app/suggestion-notifications/qa/source/quality-assurance-source.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { find, map } from 'rxjs/operators'; +import { QualityAssuranceSourceRestService } from '../../../core/suggestion-notifications/qa/source/quality-assurance-source-rest.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; + +/** + * The service handling all Quality Assurance source requests to the REST service. + */ +@Injectable() +export class QualityAssuranceSourceService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceSourceRestService} qualityAssuranceSourceRestService + */ + constructor( + private qualityAssuranceSourceRestService: QualityAssuranceSourceRestService + ) { } + + /** + * Return the list of Quality Assurance source managing pagination and errors. + * + * @param elementsPerPage + * The number of the source per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance source. + */ + public getSources(elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + + return this.qualityAssuranceSourceRestService.getSources(findListOptions).pipe( + find((rd: RemoteData>) => !rd.isResponsePending), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance source from the Broker source REST service'); + } + }) + ); + } +} diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.actions.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.actions.ts new file mode 100644 index 0000000000..2459d4352a --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceTopicObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceTopicActionTypes = { + ADD_TOPICS: type('dspace/integration/suggestion-notifications/qa/topic/ADD_TOPICS'), + RETRIEVE_ALL_TOPICS: type('dspace/integration/suggestion-notifications/qa/topic/RETRIEVE_ALL_TOPICS'), + RETRIEVE_ALL_TOPICS_ERROR: type('dspace/integration/suggestion-notifications/qa/topic/RETRIEVE_ALL_TOPICS_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance topics. + */ +export class RetrieveAllTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS; + payload: { + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveAllTopicsAction. + * + * @param elementsPerPage + * the number of topics per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number) { + this.payload = { + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance topics' error. + */ +export class RetrieveAllTopicsErrorAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance topic objects. + * Called by the ??? effect. + */ +export class AddTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.ADD_TOPICS; + payload: { + topics: QualityAssuranceTopicObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddTopicsAction. + * + * @param topics + * the list of topics + * @param totalPages + * the total available pages of topics + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance topics + */ + constructor(topics: QualityAssuranceTopicObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + topics, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceTopicsActions + = AddTopicsAction + |RetrieveAllTopicsAction + |RetrieveAllTopicsErrorAction; diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.html b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.html new file mode 100644 index 0000000000..fdc7d554a2 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.html @@ -0,0 +1,57 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+

{{'quality-assurance.topics.description'| translate:{source: sourceId} }}

+
+
+
+
+

{{'quality-assurance.topics'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.topic' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{topicElement.name}}{{topicElement.lastEvent}} +
+ +
+
+
+
+
+
+
+
diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.scss b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.spec.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.spec.ts new file mode 100644 index 0000000000..6e933a0e80 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.spec.ts @@ -0,0 +1,160 @@ +/* eslint-disable no-empty, @typescript-eslint/no-empty-function */ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf, of } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceTopicsComponent } from './quality-assurance-topics.component'; +import { SuggestionNotificationsStateService } from '../../suggestion-notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; + +describe('QualityAssuranceTopicsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceTopicsComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceTopicsParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceTopicsComponent, + TestComponent, + ], + providers: [ + { provide: SuggestionNotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), snapshot: { + paramMap: { + get: () => 'openaire', + }, + }}}, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceTopicsComponent, + // tslint:disable-next-line: no-empty + { provide: QualityAssuranceTopicsService, useValue: { setSourceId: (sourceId: string) => { } }} + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceTopics.and.returnValue(observableOf([ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceTopicsTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceTopicsCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceTopicsTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceTopicsLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceTopicsLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceTopicsProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceTopicsComponent', inject([QualityAssuranceTopicsComponent], (app: QualityAssuranceTopicsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two topics', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceTopicsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.topics$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceTopics'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceTopics).toHaveBeenCalled(); + }); + + it(('isTopicsLoading should return FALSE'), () => { + expect(comp.isTopicsLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isTopicsProcessing should return FALSE'), () => { + expect(comp.isTopicsProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceTopics should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.ts new file mode 100644 index 0000000000..a99944af6a --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.component.ts @@ -0,0 +1,155 @@ +import { Component, OnInit } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, map, take } from 'rxjs/operators'; + +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { QualityAssuranceTopicObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { hasValue } from '../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SuggestionNotificationsStateService } from '../../suggestion-notifications-state.service'; +import { AdminQualityAssuranceTopicsPageParams } from '../../../admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { ActivatedRoute } from '@angular/router'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; + +/** + * Component to display the Quality Assurance topic list. + */ +@Component({ + selector: 'ds-quality-assurance-topic', + templateUrl: './quality-assurance-topics.component.html', + styleUrls: ['./quality-assurance-topics.component.scss'], +}) +export class QualityAssuranceTopicsComponent implements OnInit { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance topic list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance topic list. + */ + public topics$: Observable; + /** + * The total number of Quality Assurance topics. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * This property represents a sourceId which is used to retrive a topic + * @type {string} + */ + public sourceId: string; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {SuggestionNotificationsStateService} notificationsStateService + */ + constructor( + private paginationService: PaginationService, + private activatedRoute: ActivatedRoute, + private notificationsStateService: SuggestionNotificationsStateService, + private qualityAssuranceTopicsService: QualityAssuranceTopicsService + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.sourceId = this.activatedRoute.snapshot.paramMap.get('sourceId'); + this.qualityAssuranceTopicsService.setSourceId(this.sourceId); + this.topics$ = this.notificationsStateService.getQualityAssuranceTopics(); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceTopicsTotals(); + } + + /** + * First Quality Assurance topics loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceTopicsLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceTopics(); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if the topics are loading, 'false' otherwise. + */ + public isTopicsLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the topics (ex.: a REST call), 'false' otherwise. + */ + public isTopicsProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsProcessing(); + } + + /** + * Dispatch the Quality Assurance topics retrival. + */ + public getQualityAssuranceTopics(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceTopics( + options.pageSize, + options.currentPage + ); + }); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: AdminQualityAssuranceTopicsPageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.effects.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.effects.ts new file mode 100644 index 0000000000..880a2d2318 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.effects.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; +import { + AddTopicsAction, + QualityAssuranceTopicActionTypes, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction, +} from './quality-assurance-topics.actions'; + +import { QualityAssuranceTopicObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { QualityAssuranceTopicRestService } from '../../../core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service'; + +/** + * Provides effect methods for the Quality Assurance topics actions. + */ +@Injectable() +export class QualityAssuranceTopicsEffects { + + /** + * Retrieve all Quality Assurance topics managing pagination and errors. + */ + @Effect() retrieveAllTopics$ = this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllTopicsAction, any]) => { + return this.qualityAssuranceTopicService.getTopics( + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((topics: PaginatedList) => + new AddTopicsAction(topics.page, topics.totalPages, topics.currentPage, topics.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllTopicsErrorAction()); + }) + ); + }) + ); + + /** + * Show a notification on error. + */ + @Effect({ dispatch: false }) retrieveAllTopicsErrorAction$ = this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.topic.error.service.retrieve')); + }) + ); + + /** + * Clear find all topics requests from cache. + */ + @Effect({ dispatch: false }) addTopicsAction$ = this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.ADD_TOPICS), + tap(() => { + this.qualityAssuranceTopicDataService.clearFindAllTopicsRequests(); + }) + ); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceTopicsService} qualityAssuranceTopicService + * @param {QualityAssuranceTopicRestService} qualityAssuranceTopicDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceTopicService: QualityAssuranceTopicsService, + private qualityAssuranceTopicDataService: QualityAssuranceTopicRestService + ) { } +} diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.spec.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.spec.ts new file mode 100644 index 0000000000..a1c002d3f2 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddTopicsAction, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction +} from './quality-assurance-topics.actions'; +import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState } from './quality-assurance-topics.reducer'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; + +describe('qualityAssuranceTopicsReducer test suite', () => { + let qualityAssuranceTopicInitialState: QualityAssuranceTopicState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceTopicInitialState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_TOPICS should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = true; + + const action = new RetrieveAllTopicsAction(elementPerPage, currentPage); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_TOPICS_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllTopicsErrorAction(); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_TOPICS should populate the State with Quality Assurance topics', () => { + const expectedState = { + topics: [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddTopicsAction( + [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); +}); diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.ts new file mode 100644 index 0000000000..355ace977d --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceTopicObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicActionTypes, QualityAssuranceTopicsActions } from './quality-assurance-topics.actions'; + +/** + * The interface representing the Quality Assurance topic state. + */ +export interface QualityAssuranceTopicState { + topics: QualityAssuranceTopicObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance topic state initialization. + */ +const qualityAssuranceTopicInitialState: QualityAssuranceTopicState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Topic Reducer + * + * @param state + * the current state initialized with qualityAssuranceTopicInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceTopicState + * the new state + */ +export function qualityAssuranceTopicsReducer(state = qualityAssuranceTopicInitialState, action: QualityAssuranceTopicsActions): QualityAssuranceTopicState { + switch (action.type) { + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS: { + return Object.assign({}, state, { + topics: [], + processing: true + }); + } + + case QualityAssuranceTopicActionTypes.ADD_TOPICS: { + return Object.assign({}, state, { + topics: action.payload.topics, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.spec.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.spec.ts new file mode 100644 index 0000000000..ba1399fcd4 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.spec.ts @@ -0,0 +1,70 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { QualityAssuranceTopicRestService } from '../../../core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceTopicRestService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceTopicsService', () => { + let service: QualityAssuranceTopicsService; + let restService: QualityAssuranceTopicRestService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceTopicRestService, useClass: getMockQualityAssuranceTopicRestService }, + { provide: QualityAssuranceTopicsService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.get(QualityAssuranceTopicRestService); + restServiceAsAny = restService; + restServiceAsAny.getTopics.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceTopicsService(restService); + serviceAsAny = service; + }); + + describe('getTopics', () => { + it('Should proxy the call to qualityAssuranceTopicRestService.getTopics', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', 'ENRICH!MORE!ABSTRACT')] + }; + service.setSourceId('ENRICH!MORE!ABSTRACT'); + const result = service.getTopics(elementsPerPage, currentPage); + expect((service as any).qualityAssuranceTopicRestService.getTopics).toHaveBeenCalledWith(findListOptions); + }); + + it('Should return a paginated list of Quality Assurance topics', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getTopics(elementsPerPage, currentPage); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.ts b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.ts new file mode 100644 index 0000000000..e3e4b1aa93 --- /dev/null +++ b/src/app/suggestion-notifications/qa/topics/quality-assurance-topics.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { find, map } from 'rxjs/operators'; +import { QualityAssuranceTopicRestService } from '../../../core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceTopicObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; + +/** + * The service handling all Quality Assurance topic requests to the REST service. + */ +@Injectable() +export class QualityAssuranceTopicsService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceTopicRestService} qualityAssuranceTopicRestService + */ + constructor( + private qualityAssuranceTopicRestService: QualityAssuranceTopicRestService + ) { } + + /** + * sourceId used to get topics + */ + sourceId: string; + + /** + * Return the list of Quality Assurance topics managing pagination and errors. + * + * @param elementsPerPage + * The number of the topics per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance topics. + */ + public getTopics(elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', this.sourceId)] + }; + + return this.qualityAssuranceTopicRestService.getTopics(findListOptions).pipe( + find((rd: RemoteData>) => !rd.isResponsePending), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance topics from the Broker topics REST service'); + } + }) + ); + } + + /** + * set sourceId which is used to get topics + * @param sourceId string + */ + setSourceId(sourceId: string) { + this.sourceId = sourceId; + } +} diff --git a/src/app/openaire/reciter-suggestions/selectors.ts b/src/app/suggestion-notifications/reciter-suggestions/selectors.ts similarity index 65% rename from src/app/openaire/reciter-suggestions/selectors.ts rename to src/app/suggestion-notifications/reciter-suggestions/selectors.ts index cfbb36d4c2..6ba474bfd8 100644 --- a/src/app/openaire/reciter-suggestions/selectors.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/selectors.ts @@ -1,6 +1,6 @@ import {createFeatureSelector, createSelector, MemoizedSelector} from '@ngrx/store'; -import { openaireSelector, OpenaireState } from '../openaire.reducer'; -import { OpenaireSuggestionTarget } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { suggestionNotificationsSelector, SuggestionNotificationsState } from '../suggestion-notifications.reducer'; +import { OpenaireSuggestionTarget } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { SuggestionTargetState } from './suggestion-targets/suggestion-targets.reducer'; import {subStateSelector} from '../../submission/selectors'; @@ -8,9 +8,9 @@ import {subStateSelector} from '../../submission/selectors'; * Returns the Reciter Suggestion Target state. * @function _getReciterSuggestionTargetState * @param {AppState} state Top level state. - * @return {OpenaireState} + * @return {SuggestionNotificationsState} */ -const _getReciterSuggestionTargetState = createFeatureSelector('openaire'); +const _getReciterSuggestionTargetState = createFeatureSelector('openaire'); // Reciter Suggestion Targets // ---------------------------------------------------------------------------- @@ -18,10 +18,10 @@ const _getReciterSuggestionTargetState = createFeatureSelector('o /** * Returns the Reciter Suggestion Targets State. * @function reciterSuggestionTargetStateSelector - * @return {OpenaireState} + * @return {SuggestionNotificationsState} */ -export function reciterSuggestionTargetStateSelector(): MemoizedSelector { - return subStateSelector(openaireSelector, 'suggestionTarget'); +export function reciterSuggestionTargetStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'suggestionTarget'); } /** @@ -29,8 +29,8 @@ export function reciterSuggestionTargetStateSelector(): MemoizedSelector { - return subStateSelector(reciterSuggestionTargetStateSelector(), 'targets'); +export function reciterSuggestionTargetObjectSelector(): MemoizedSelector { + return subStateSelector(reciterSuggestionTargetStateSelector(), 'targets'); } /** @@ -39,7 +39,7 @@ export function reciterSuggestionTargetObjectSelector(): MemoizedSelector state.suggestionTarget.loaded + (state: SuggestionNotificationsState) => state.suggestionTarget.loaded ); /** @@ -48,7 +48,7 @@ export const isReciterSuggestionTargetLoadedSelector = createSelector(_getRecite * @return {boolean} */ export const isreciterSuggestionTargetProcessingSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.processing + (state: SuggestionNotificationsState) => state.suggestionTarget.processing ); /** @@ -57,7 +57,7 @@ export const isreciterSuggestionTargetProcessingSelector = createSelector(_getRe * @return {number} */ export const getreciterSuggestionTargetTotalPagesSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.totalPages + (state: SuggestionNotificationsState) => state.suggestionTarget.totalPages ); /** @@ -66,7 +66,7 @@ export const getreciterSuggestionTargetTotalPagesSelector = createSelector(_getR * @return {number} */ export const getreciterSuggestionTargetCurrentPageSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.currentPage + (state: SuggestionNotificationsState) => state.suggestionTarget.currentPage ); /** @@ -75,7 +75,7 @@ export const getreciterSuggestionTargetCurrentPageSelector = createSelector(_get * @return {number} */ export const getreciterSuggestionTargetTotalsSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.totalElements + (state: SuggestionNotificationsState) => state.suggestionTarget.totalElements ); /** @@ -84,7 +84,7 @@ export const getreciterSuggestionTargetTotalsSelector = createSelector(_getRecit * @return {OpenaireSuggestionTarget[]} */ export const getCurrentUserSuggestionTargetsSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.currentUserTargets + (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargets ); /** @@ -93,5 +93,5 @@ export const getCurrentUserSuggestionTargetsSelector = createSelector(_getRecite * @return {boolean} */ export const getCurrentUserSuggestionTargetsVisitedSelector = createSelector(_getReciterSuggestionTargetState, - (state: OpenaireState) => state.suggestionTarget.currentUserTargetsVisited + (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargetsVisited ); diff --git a/src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.html diff --git a/src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss diff --git a/src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts similarity index 95% rename from src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts index cf21901123..7a3312af0c 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { OpenaireSuggestion } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestion } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestion-list-element.component'; import { Collection } from '../../../core/shared/collection.model'; import { take } from 'rxjs/operators'; diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html diff --git a/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts similarity index 74% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts index e0536ae723..7bd5fc6e1b 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts @@ -1,6 +1,6 @@ import { Component, Input } from '@angular/core'; import { fadeIn } from '../../../../shared/animations/fade'; -import { SuggestionEvidences } from '../../../../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; +import { SuggestionEvidences } from '../../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; @Component({ selector: 'ds-suggestion-evidences', diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss diff --git a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts similarity index 94% rename from src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts index 8227dc3213..ca531655fc 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts @@ -3,7 +3,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { fadeIn } from '../../../shared/animations/fade'; -import { OpenaireSuggestion } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestion } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; import { Item } from '../../../core/shared/item.model'; import { isNotEmpty } from '../../../shared/empty.util'; diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts similarity index 96% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts index 33dfb1474b..627b815554 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; /** * For each action type in an action group, make a simple diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.html diff --git a/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts similarity index 97% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts index b9ed6c4e87..7cd882d01e 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, take } from 'rxjs/operators'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { hasValue } from '../../../shared/empty.util'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SuggestionTargetsStateService } from './suggestion-targets.state.service'; diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts similarity index 96% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts index 29672ad49b..01b305bbd4 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts @@ -18,7 +18,7 @@ import { PaginatedList } from '../../../core/data/paginated-list.model'; import { SuggestionsService } from '../suggestions.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { AuthActionTypes, RetrieveAuthenticatedEpersonSuccessAction } from '../../../core/auth/auth.actions'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { EPerson } from '../../../core/eperson/models/eperson.model'; /** diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts similarity index 94% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts index f8bd53ec05..b0b551dfa9 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts @@ -1,5 +1,5 @@ import { SuggestionTargetActionTypes, SuggestionTargetsActions } from './suggestion-targets.actions'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; /** * The interface representing the OpenAIRE suggestion targets state. diff --git a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts similarity index 93% rename from src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts index 2e05bce0a9..164750d221 100644 --- a/src/app/openaire/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts @@ -13,14 +13,14 @@ import { isreciterSuggestionTargetProcessingSelector, reciterSuggestionTargetObjectSelector } from '../selectors'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { ClearSuggestionTargetsAction, MarkUserSuggestionsAsVisitedAction, RefreshUserSuggestionsAction, RetrieveTargetsBySourceAction } from './suggestion-targets.actions'; -import { OpenaireState } from '../../openaire.reducer'; +import { SuggestionNotificationsState } from '../../suggestion-notifications.reducer'; /** * The service handling the Suggestion targets State. @@ -30,9 +30,9 @@ export class SuggestionTargetsStateService { /** * Initialize the service variables. - * @param {Store} store + * @param {Store} store */ - constructor(private store: Store) { } + constructor(private store: Store) { } /** * Returns the list of Reciter Suggestion Targets from the state. diff --git a/src/app/openaire/reciter-suggestions/suggestions-notification/suggestions-notification.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestions-notification/suggestions-notification.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.html diff --git a/src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/openaire/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts similarity index 91% rename from src/app/openaire/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts index 094dfab017..19f94c433d 100644 --- a/src/app/openaire/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { TranslateService } from '@ngx-translate/core'; import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; diff --git a/src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.html b/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.html similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.html rename to src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.html diff --git a/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.scss b/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts similarity index 100% rename from src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts diff --git a/src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts similarity index 93% rename from src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts index 6135cd99ea..9478ed2d1d 100644 --- a/src/app/openaire/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts @@ -4,7 +4,7 @@ import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion- import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { SuggestionsService } from '../suggestions.service'; import { takeUntil } from 'rxjs/operators'; -import { OpenaireSuggestionTarget } from '../../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { isNotEmpty } from '../../../shared/empty.util'; import { combineLatest, Subject } from 'rxjs'; @@ -40,7 +40,7 @@ export class SuggestionsPopupComponent implements OnInit, OnDestroy { if (!visited) { suggestions.forEach((suggestionTarget: OpenaireSuggestionTarget) => this.showNotificationForNewSuggestions(suggestionTarget)); this.reciterSuggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction(); - notifier.next(); + notifier.next(null); notifier.complete(); } } diff --git a/src/app/openaire/reciter-suggestions/suggestions.service.ts b/src/app/suggestion-notifications/reciter-suggestions/suggestions.service.ts similarity index 95% rename from src/app/openaire/reciter-suggestions/suggestions.service.ts rename to src/app/suggestion-notifications/reciter-suggestions/suggestions.service.ts index 67b496b903..c4a2fa4b4b 100644 --- a/src/app/openaire/reciter-suggestions/suggestions.service.ts +++ b/src/app/suggestion-notifications/reciter-suggestions/suggestions.service.ts @@ -3,11 +3,11 @@ import { Injectable } from '@angular/core'; import { of, forkJoin, Observable } from 'rxjs'; import { catchError, map, mergeMap, take } from 'rxjs/operators'; -import { OpenaireSuggestionsDataService } from '../../core/openaire/reciter-suggestions/openaire-suggestions-data.service'; +import { OpenaireSuggestionsDataService } from '../../core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; -import { OpenaireSuggestionTarget } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionTarget } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; import { AuthService } from '../../core/auth/auth.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; @@ -19,7 +19,7 @@ import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; -import { OpenaireSuggestion } from '../../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestion } from '../../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; import { TranslateService } from '@ngx-translate/core'; import { NoContent } from '../../core/shared/NoContent.model'; @@ -147,6 +147,7 @@ export class SuggestionsService { */ public retrieveCurrentUserSuggestions(userUuid: string): Observable { return this.researcherProfileService.findById(userUuid).pipe( + getFirstSucceededRemoteDataPayload(), mergeMap((profile: ResearcherProfile) => { if (isNotEmpty(profile)) { return this.researcherProfileService.findRelatedItemId(profile).pipe( diff --git a/src/app/suggestion-notifications/selectors.ts b/src/app/suggestion-notifications/selectors.ts new file mode 100644 index 0000000000..c5947e3196 --- /dev/null +++ b/src/app/suggestion-notifications/selectors.ts @@ -0,0 +1,147 @@ +import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store'; +import { subStateSelector } from '../shared/selector.util'; +import { suggestionNotificationsSelector, SuggestionNotificationsState } from './suggestion-notifications.reducer'; +import { QualityAssuranceTopicObject } from '../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicState } from './qa/topics/quality-assurance-topics.reducer'; +import { QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; +import { QualityAssuranceSourceObject } from '../core/suggestion-notifications/qa/models/quality-assurance-source.model'; + +/** + * Returns the Notifications state. + * @function _getNotificationsState + * @param {AppState} state Top level state. + * @return {SuggestionNotificationsState} + */ +const _getNotificationsState = createFeatureSelector('notifications'); + +// Quality Assurance topics +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance topics State. + * @function qualityAssuranceTopicsStateSelector + * @return {QualityAssuranceTopicState} + */ +export function qualityAssuranceTopicsStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaTopic'); +} + +/** + * Returns the Quality Assurance topics list. + * @function qualityAssuranceTopicsObjectSelector + * @return {QualityAssuranceTopicObject[]} + */ +export function qualityAssuranceTopicsObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceTopicsStateSelector(), 'topics'); +} + +/** + * Returns true if the Quality Assurance topics are loaded. + * @function isQualityAssuranceTopicsLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.processing +); + +/** + * Returns the total available pages of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalPages +); + +/** + * Returns the current page of Quality Assurance topics. + * @function getQualityAssuranceTopicsCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceTopicsCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.currentPage +); + +/** + * Returns the total number of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalsSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalElements +); + +// Quality Assurance source +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance source State. + * @function qualityAssuranceSourceStateSelector + * @return {QualityAssuranceSourceState} + */ + export function qualityAssuranceSourceStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaSource'); +} + +/** + * Returns the Quality Assurance source list. + * @function qualityAssuranceSourceObjectSelector + * @return {QualityAssuranceSourceObject[]} + */ +export function qualityAssuranceSourceObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceSourceStateSelector(), 'source'); +} + +/** + * Returns true if the Quality Assurance source are loaded. + * @function isQualityAssuranceSourceLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.processing +); + +/** + * Returns the total available pages of Quality Assurance source. + * @function getQualityAssuranceSourceTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalPages +); + +/** + * Returns the current page of Quality Assurance source. + * @function getQualityAssuranceSourceCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceSourceCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.currentPage +); + +/** + * Returns the total number of Quality Assurance source. + * @function getQualityAssuranceSourceTotalsSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalElements +); diff --git a/src/app/suggestion-notifications/suggestion-notifications-effects.ts b/src/app/suggestion-notifications/suggestion-notifications-effects.ts new file mode 100644 index 0000000000..30d6db20dd --- /dev/null +++ b/src/app/suggestion-notifications/suggestion-notifications-effects.ts @@ -0,0 +1,9 @@ +import { QualityAssuranceSourceEffects } from './qa/source/quality-assurance-source.effects'; +import { QualityAssuranceTopicsEffects } from './qa/topics/quality-assurance-topics.effects'; +import {SuggestionTargetsEffects} from './reciter-suggestions/suggestion-targets/suggestion-targets.effects'; + +export const suggestionNotificationsEffects = [ + QualityAssuranceTopicsEffects, + QualityAssuranceSourceEffects, + SuggestionTargetsEffects +]; diff --git a/src/app/suggestion-notifications/suggestion-notifications-state.service.spec.ts b/src/app/suggestion-notifications/suggestion-notifications-state.service.spec.ts new file mode 100644 index 0000000000..b04368cfad --- /dev/null +++ b/src/app/suggestion-notifications/suggestion-notifications-state.service.spec.ts @@ -0,0 +1,541 @@ +import { TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { cold } from 'jasmine-marbles'; +import { suggestionNotificationsReducers } from './suggestion-notifications.reducer'; +import { SuggestionNotificationsStateService } from './suggestion-notifications-state.service'; +import { + qualityAssuranceSourceObjectMissingPid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid, + qualityAssuranceTopicObjectMissingPid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../shared/mocks/notifications.mock'; +import { RetrieveAllTopicsAction } from './qa/topics/quality-assurance-topics.actions'; +import { RetrieveAllSourceAction } from './qa/source/quality-assurance-source.actions'; + +describe('NotificationsStateService', () => { + let service: SuggestionNotificationsStateService; + let serviceAsAny: any; + let store: any; + let initialState: any; + + describe('Topis State', () => { + function init(mode: string) { + if (mode === 'empty') { + initialState = { + notifications: { + qaTopic: { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + totalLoadedPages: 0 + } + } + }; + } else { + initialState = { + notifications: { + qaTopic: { + topics: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMissingPid + ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 1, + totalElements: 3, + totalLoadedPages: 1 + } + } + }; + } + } + + describe('Testing methods with empty topic objects', () => { + beforeEach(async () => { + init('empty'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceTopics', () => { + it('Should return an empty array', () => { + const result = service.getQualityAssuranceTopics(); + const expected = cold('(a)', { + a: [] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotalPages', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceTopicsTotalPages(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsCurrentPage', () => { + it('Should return minus one (0)', () => { + const result = service.getQualityAssuranceTopicsCurrentPage(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotals', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceTopicsTotals(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoading', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceTopicsLoading(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoaded', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsLoaded(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing methods with topic objects', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceTopics', () => { + it('Should return an array of topics', () => { + const result = service.getQualityAssuranceTopics(); + const expected = cold('(a)', { + a: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMissingPid + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotalPages', () => { + it('Should return one (1)', () => { + const result = service.getQualityAssuranceTopicsTotalPages(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsCurrentPage', () => { + it('Should return minus zero (1)', () => { + const result = service.getQualityAssuranceTopicsCurrentPage(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceTopicsTotals', () => { + it('Should return three (3)', () => { + const result = service.getQualityAssuranceTopicsTotals(); + const expected = cold('(a)', { + a: 3 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoading', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsLoading(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsLoaded', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceTopicsLoaded(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceTopicsProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceTopicsProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing the topic dispatch methods', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('dispatchRetrieveQualityAssuranceTopics', () => { + it('Should call store.dispatch', () => { + const elementsPerPage = 3; + const currentPage = 1; + const action = new RetrieveAllTopicsAction(elementsPerPage, currentPage); + service.dispatchRetrieveQualityAssuranceTopics(elementsPerPage, currentPage); + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(action); + }); + }); + }); + }); + + describe('Source State', () => { + function init(mode: string) { + if (mode === 'empty') { + initialState = { + notifications: { + qaSource: { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0, + totalLoadedPages: 0 + } + } + }; + } else { + initialState = { + notifications: { + qaSource: { + source: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMissingPid + ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 1, + totalElements: 3, + totalLoadedPages: 1 + } + } + }; + } + } + + describe('Testing methods with empty source objects', () => { + beforeEach(async () => { + init('empty'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceSource', () => { + it('Should return an empty array', () => { + const result = service.getQualityAssuranceSource(); + const expected = cold('(a)', { + a: [] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotalPages', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceSourceTotalPages(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourcesCurrentPage', () => { + it('Should return minus one (0)', () => { + const result = service.getQualityAssuranceSourceCurrentPage(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotals', () => { + it('Should return zero (0)', () => { + const result = service.getQualityAssuranceSourceTotals(); + const expected = cold('(a)', { + a: 0 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoading', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceSourceLoading(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoaded', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceLoaded(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing methods with Source objects', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('getQualityAssuranceSource', () => { + it('Should return an array of Source', () => { + const result = service.getQualityAssuranceSource(); + const expected = cold('(a)', { + a: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMissingPid + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotalPages', () => { + it('Should return one (1)', () => { + const result = service.getQualityAssuranceSourceTotalPages(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceCurrentPage', () => { + it('Should return minus zero (1)', () => { + const result = service.getQualityAssuranceSourceCurrentPage(); + const expected = cold('(a)', { + a: 1 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getQualityAssuranceSourceTotals', () => { + it('Should return three (3)', () => { + const result = service.getQualityAssuranceSourceTotals(); + const expected = cold('(a)', { + a: 3 + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoading', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceLoading(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceLoaded', () => { + it('Should return TRUE', () => { + const result = service.isQualityAssuranceSourceLoaded(); + const expected = cold('(a)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('isQualityAssuranceSourceProcessing', () => { + it('Should return FALSE', () => { + const result = service.isQualityAssuranceSourceProcessing(); + const expected = cold('(a)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('Testing the Source dispatch methods', () => { + beforeEach(async () => { + init('full'); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ notifications: suggestionNotificationsReducers } as any), + ], + providers: [ + provideMockStore({ initialState }), + { provide: SuggestionNotificationsStateService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + store = TestBed.get(Store); + service = new SuggestionNotificationsStateService(store); + serviceAsAny = service; + spyOn(store, 'dispatch'); + }); + + describe('dispatchRetrieveQualityAssuranceSource', () => { + it('Should call store.dispatch', () => { + const elementsPerPage = 3; + const currentPage = 1; + const action = new RetrieveAllSourceAction(elementsPerPage, currentPage); + service.dispatchRetrieveQualityAssuranceSource(elementsPerPage, currentPage); + expect(serviceAsAny.store.dispatch).toHaveBeenCalledWith(action); + }); + }); + }); + }); + + +}); diff --git a/src/app/suggestion-notifications/suggestion-notifications-state.service.ts b/src/app/suggestion-notifications/suggestion-notifications-state.service.ts new file mode 100644 index 0000000000..ec1ea2e039 --- /dev/null +++ b/src/app/suggestion-notifications/suggestion-notifications-state.service.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + getQualityAssuranceTopicsCurrentPageSelector, + getQualityAssuranceTopicsTotalPagesSelector, + getQualityAssuranceTopicsTotalsSelector, + isQualityAssuranceTopicsLoadedSelector, + qualityAssuranceTopicsObjectSelector, + isQualityAssuranceTopicsProcessingSelector, + qualityAssuranceSourceObjectSelector, + isQualityAssuranceSourceLoadedSelector, + isQualityAssuranceSourceProcessingSelector, + getQualityAssuranceSourceTotalPagesSelector, + getQualityAssuranceSourceCurrentPageSelector, + getQualityAssuranceSourceTotalsSelector +} from './selectors'; +import { QualityAssuranceTopicObject } from '../core/suggestion-notifications/qa/models/quality-assurance-topic.model'; +import { SuggestionNotificationsState } from './suggestion-notifications.reducer'; +import { RetrieveAllTopicsAction } from './qa/topics/quality-assurance-topics.actions'; +import { QualityAssuranceSourceObject } from '../core/suggestion-notifications/qa/models/quality-assurance-source.model'; +import { RetrieveAllSourceAction } from './qa/source/quality-assurance-source.actions'; + +/** + * The service handling the Notifications State. + */ +@Injectable() +export class SuggestionNotificationsStateService { + + /** + * Initialize the service variables. + * @param {Store} store + */ + constructor(private store: Store) { } + + // Quality Assurance topics + // -------------------------------------------------------------------------- + + /** + * Returns the list of Quality Assurance topics from the state. + * + * @return Observable + * The list of Quality Assurance topics. + */ + public getQualityAssuranceTopics(): Observable { + return this.store.pipe(select(qualityAssuranceTopicsObjectSelector())); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if the topics are loading, 'false' otherwise. + */ + public isQualityAssuranceTopicsLoading(): Observable { + return this.store.pipe( + select(isQualityAssuranceTopicsLoadedSelector), + map((loaded: boolean) => !loaded) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (whether or not they were loaded). + * + * @return Observable + * 'true' if the topics are loaded, 'false' otherwise. + */ + public isQualityAssuranceTopicsLoaded(): Observable { + return this.store.pipe(select(isQualityAssuranceTopicsLoadedSelector)); + } + + /** + * Returns the information about the processing status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the topics (ex.: a REST call), 'false' otherwise. + */ + public isQualityAssuranceTopicsProcessing(): Observable { + return this.store.pipe(select(isQualityAssuranceTopicsProcessingSelector)); + } + + /** + * Returns, from the state, the total available pages of the Quality Assurance topics. + * + * @return Observable + * The number of the Quality Assurance topics pages. + */ + public getQualityAssuranceTopicsTotalPages(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsTotalPagesSelector)); + } + + /** + * Returns the current page of the Quality Assurance topics, from the state. + * + * @return Observable + * The number of the current Quality Assurance topics page. + */ + public getQualityAssuranceTopicsCurrentPage(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsCurrentPageSelector)); + } + + /** + * Returns the total number of the Quality Assurance topics. + * + * @return Observable + * The number of the Quality Assurance topics. + */ + public getQualityAssuranceTopicsTotals(): Observable { + return this.store.pipe(select(getQualityAssuranceTopicsTotalsSelector)); + } + + /** + * Dispatch a request to change the Quality Assurance topics state, retrieving the topics from the server. + * + * @param elementsPerPage + * The number of the topics per page. + * @param currentPage + * The number of the current page. + */ + public dispatchRetrieveQualityAssuranceTopics(elementsPerPage: number, currentPage: number): void { + this.store.dispatch(new RetrieveAllTopicsAction(elementsPerPage, currentPage)); + } + + // Quality Assurance source + // -------------------------------------------------------------------------- + + /** + * Returns the list of Quality Assurance source from the state. + * + * @return Observable + * The list of Quality Assurance source. + */ + public getQualityAssuranceSource(): Observable { + return this.store.pipe(select(qualityAssuranceSourceObjectSelector())); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if the source are loading, 'false' otherwise. + */ + public isQualityAssuranceSourceLoading(): Observable { + return this.store.pipe( + select(isQualityAssuranceSourceLoadedSelector), + map((loaded: boolean) => !loaded) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (whether or not they were loaded). + * + * @return Observable + * 'true' if the source are loaded, 'false' otherwise. + */ + public isQualityAssuranceSourceLoaded(): Observable { + return this.store.pipe(select(isQualityAssuranceSourceLoadedSelector)); + } + + /** + * Returns the information about the processing status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the source (ex.: a REST call), 'false' otherwise. + */ + public isQualityAssuranceSourceProcessing(): Observable { + return this.store.pipe(select(isQualityAssuranceSourceProcessingSelector)); + } + + /** + * Returns, from the state, the total available pages of the Quality Assurance source. + * + * @return Observable + * The number of the Quality Assurance source pages. + */ + public getQualityAssuranceSourceTotalPages(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceTotalPagesSelector)); + } + + /** + * Returns the current page of the Quality Assurance source, from the state. + * + * @return Observable + * The number of the current Quality Assurance source page. + */ + public getQualityAssuranceSourceCurrentPage(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceCurrentPageSelector)); + } + + /** + * Returns the total number of the Quality Assurance source. + * + * @return Observable + * The number of the Quality Assurance source. + */ + public getQualityAssuranceSourceTotals(): Observable { + return this.store.pipe(select(getQualityAssuranceSourceTotalsSelector)); + } + + /** + * Dispatch a request to change the Quality Assurance source state, retrieving the source from the server. + * + * @param elementsPerPage + * The number of the source per page. + * @param currentPage + * The number of the current page. + */ + public dispatchRetrieveQualityAssuranceSource(elementsPerPage: number, currentPage: number): void { + this.store.dispatch(new RetrieveAllSourceAction(elementsPerPage, currentPage)); + } +} diff --git a/src/app/suggestion-notifications/suggestion-notifications.module.ts b/src/app/suggestion-notifications/suggestion-notifications.module.ts new file mode 100644 index 0000000000..b69427d70d --- /dev/null +++ b/src/app/suggestion-notifications/suggestion-notifications.module.ts @@ -0,0 +1,104 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Action, StoreConfig, StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; + +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; +import { storeModuleConfig } from '../app.reducer'; +import { QualityAssuranceTopicsComponent } from './qa/topics/quality-assurance-topics.component'; +import { QualityAssuranceEventsComponent } from './qa/events/quality-assurance-events.component'; +import { SuggestionNotificationsStateService } from './suggestion-notifications-state.service'; +import { suggestionNotificationsReducers, SuggestionNotificationsState } from './suggestion-notifications.reducer'; +import { suggestionNotificationsEffects } from './suggestion-notifications-effects'; +import { QualityAssuranceTopicsService } from './qa/topics/quality-assurance-topics.service'; +import { QualityAssuranceTopicRestService } from '../core/suggestion-notifications/qa/topics/quality-assurance-topic-rest.service'; +import { QualityAssuranceEventRestService } from '../core/suggestion-notifications/qa/events/quality-assurance-event-rest.service'; +import { ProjectEntryImportModalComponent } from './qa/project-entry-import-modal/project-entry-import-modal.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchModule } from '../shared/search/search.module'; +import { QualityAssuranceSourceComponent } from './qa/source/quality-assurance-source.component'; +import { QualityAssuranceSourceService } from './qa/source/quality-assurance-source.service'; +import { QualityAssuranceSourceRestService } from '../core/suggestion-notifications/qa/source/quality-assurance-source-rest.service'; +import { + SuggestionsNotificationComponent +} from './reciter-suggestions/suggestions-notification/suggestions-notification.component'; +import {SuggestionsPopupComponent} from './reciter-suggestions/suggestions-popup/suggestions-popup.component'; +import { + SuggestionEvidencesComponent +} from './reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { + SuggestionListElementComponent +} from './reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; +import {SuggestionActionsComponent} from './reciter-suggestions/suggestion-actions/suggestion-actions.component'; +import {SuggestionTargetsComponent} from './reciter-suggestions/suggestion-targets/suggestion-targets.component'; +import {SuggestionTargetsStateService} from './reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; +import {SuggestionsService} from './reciter-suggestions/suggestions.service'; +import {OpenaireSuggestionsDataService} from '../core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service'; + +const MODULES = [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + StoreModule.forFeature('notifications', suggestionNotificationsReducers, storeModuleConfig as StoreConfig), + EffectsModule.forFeature(suggestionNotificationsEffects), + TranslateModule +]; + +const COMPONENTS = [ + QualityAssuranceTopicsComponent, + QualityAssuranceEventsComponent, + QualityAssuranceSourceComponent, + SuggestionTargetsComponent, + SuggestionActionsComponent, + SuggestionListElementComponent, + SuggestionEvidencesComponent, + SuggestionsPopupComponent, + SuggestionsNotificationComponent +]; + +const DIRECTIVES = [ ]; + +const ENTRY_COMPONENTS = [ + ProjectEntryImportModalComponent +]; + +const PROVIDERS = [ + SuggestionNotificationsStateService, + QualityAssuranceTopicsService, + QualityAssuranceSourceService, + QualityAssuranceTopicRestService, + QualityAssuranceSourceRestService, + QualityAssuranceEventRestService, + SuggestionTargetsStateService, + SuggestionsService, + OpenaireSuggestionsDataService +]; + +@NgModule({ + imports: [ + ...MODULES, + SearchModule + ], + declarations: [ + ...COMPONENTS, + ...DIRECTIVES, + ...ENTRY_COMPONENTS + ], + providers: [ + ...PROVIDERS + ], + entryComponents: [ + ...ENTRY_COMPONENTS + ], + exports: [ + ...COMPONENTS, + ...DIRECTIVES + ] +}) + +/** + * This module handles all components that are necessary for the OpenAIRE components + */ +export class SuggestionNotificationsModule { +} diff --git a/src/app/suggestion-notifications/suggestion-notifications.reducer.ts b/src/app/suggestion-notifications/suggestion-notifications.reducer.ts new file mode 100644 index 0000000000..cdd072bb39 --- /dev/null +++ b/src/app/suggestion-notifications/suggestion-notifications.reducer.ts @@ -0,0 +1,24 @@ +import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; +import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; +import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState, } from './qa/topics/quality-assurance-topics.reducer'; +import { + SuggestionTargetsReducer, + SuggestionTargetState +} from './reciter-suggestions/suggestion-targets/suggestion-targets.reducer'; + +/** + * The OpenAIRE State + */ +export interface SuggestionNotificationsState { + 'qaTopic': QualityAssuranceTopicState; + 'qaSource': QualityAssuranceSourceState; + 'suggestionTarget': SuggestionTargetState; +} + +export const suggestionNotificationsReducers: ActionReducerMap = { + qaTopic: qualityAssuranceTopicsReducer, + qaSource: qualityAssuranceSourceReducer, + suggestionTarget: SuggestionTargetsReducer +}; + +export const suggestionNotificationsSelector = createFeatureSelector('notifications'); diff --git a/src/app/suggestions-page/suggestions-page.component.spec.ts b/src/app/suggestions-page/suggestions-page.component.spec.ts index ad518930d9..aef9332605 100644 --- a/src/app/suggestions-page/suggestions-page.component.spec.ts +++ b/src/app/suggestions-page/suggestions-page.component.spec.ts @@ -7,13 +7,13 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { SuggestionsPageComponent } from './suggestions-page.component'; -import { SuggestionListElementComponent } from '../openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; -import { SuggestionsService } from '../openaire/reciter-suggestions/suggestions.service'; -import { getMockOpenaireStateService, getMockSuggestionsService } from '../shared/mocks/openaire.mock'; +import { SuggestionListElementComponent } from '../suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; +import { SuggestionsService } from '../suggestion-notifications/reciter-suggestions/suggestions.service'; +import { getMockSuggestionNotificationsStateService, getMockSuggestionsService } from '../shared/mocks/openaire.mock'; import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; -import { OpenaireSuggestion } from '../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestion } from '../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; import { mockSuggestionPublicationOne, mockSuggestionPublicationTwo } from '../shared/mocks/reciter-suggestion.mock'; -import { SuggestionEvidencesComponent } from '../openaire/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { SuggestionEvidencesComponent } from '../suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; import { ObjectKeysPipe } from '../shared/utils/object-keys-pipe'; import { VarDirective } from '../shared/utils/var.directive'; import { ActivatedRoute, Router } from '@angular/router'; @@ -23,7 +23,7 @@ import { AuthService } from '../core/auth/auth.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; import { getMockTranslateService } from '../shared/mocks/translate.service.mock'; -import { SuggestionTargetsStateService } from '../openaire/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; +import { SuggestionTargetsStateService } from '../suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; import { PageInfo } from '../core/shared/page-info.model'; @@ -37,7 +37,7 @@ describe('SuggestionPageComponent', () => { let fixture: ComponentFixture; let scheduler: TestScheduler; const mockSuggestionsService = getMockSuggestionsService(); - const mockSuggestionsTargetStateService = getMockOpenaireStateService(); + const mockSuggestionsTargetStateService = getMockSuggestionNotificationsStateService(); const suggestionTargetsList: PaginatedList = buildPaginatedList(new PageInfo(), [mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); const router = new RouterStub(); const routeStub = { diff --git a/src/app/suggestions-page/suggestions-page.component.ts b/src/app/suggestions-page/suggestions-page.component.ts index 3d6ced5ef5..e8e76ff606 100644 --- a/src/app/suggestions-page/suggestions-page.component.ts +++ b/src/app/suggestions-page/suggestions-page.component.ts @@ -9,14 +9,14 @@ import { SortDirection, SortOptions, } from '../core/cache/models/sort-options.m import { PaginatedList } from '../core/data/paginated-list.model'; import { RemoteData } from '../core/data/remote-data'; import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; -import { SuggestionBulkResult, SuggestionsService } from '../openaire/reciter-suggestions/suggestions.service'; +import { SuggestionBulkResult, SuggestionsService } from '../suggestion-notifications/reciter-suggestions/suggestions.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { OpenaireSuggestion } from '../core/openaire/reciter-suggestions/models/openaire-suggestion.model'; -import { OpenaireSuggestionTarget } from '../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestion } from '../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion.model'; +import { OpenaireSuggestionTarget } from '../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; import { AuthService } from '../core/auth/auth.service'; -import { SuggestionApproveAndImport } from '../openaire/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; +import { SuggestionApproveAndImport } from '../suggestion-notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; -import { SuggestionTargetsStateService } from '../openaire/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; +import { SuggestionTargetsStateService } from '../suggestion-notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { PaginationService } from '../core/pagination/pagination.service'; import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; diff --git a/src/app/suggestions-page/suggestions-page.module.ts b/src/app/suggestions-page/suggestions-page.module.ts index 3fad2d0c19..726af26c0f 100644 --- a/src/app/suggestions-page/suggestions-page.module.ts +++ b/src/app/suggestions-page/suggestions-page.module.ts @@ -4,16 +4,16 @@ import { CommonModule } from '@angular/common'; import { SuggestionsPageComponent } from './suggestions-page.component'; import { SharedModule } from '../shared/shared.module'; import { SuggestionsPageRoutingModule } from './suggestions-page-routing.module'; -import { SuggestionsService } from '../openaire/reciter-suggestions/suggestions.service'; -import { OpenaireSuggestionsDataService } from '../core/openaire/reciter-suggestions/openaire-suggestions-data.service'; -import { OpenaireModule } from '../openaire/openaire.module'; +import { SuggestionsService } from '../suggestion-notifications/reciter-suggestions/suggestions.service'; +import { OpenaireSuggestionsDataService } from '../core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service'; +import {SuggestionNotificationsModule} from '../suggestion-notifications/suggestion-notifications.module'; @NgModule({ declarations: [SuggestionsPageComponent], imports: [ CommonModule, SharedModule, - OpenaireModule, + SuggestionNotificationsModule, SuggestionsPageRoutingModule ], providers: [ diff --git a/src/app/suggestions-page/suggestions-page.resolver.ts b/src/app/suggestions-page/suggestions-page.resolver.ts index 0027ae2e77..cf22b04aa6 100644 --- a/src/app/suggestions-page/suggestions-page.resolver.ts +++ b/src/app/suggestions-page/suggestions-page.resolver.ts @@ -6,8 +6,8 @@ import { find } from 'rxjs/operators'; import { RemoteData } from '../core/data/remote-data'; import { hasValue } from '../shared/empty.util'; -import { OpenaireSuggestionsDataService } from '../core/openaire/reciter-suggestions/openaire-suggestions-data.service'; -import { OpenaireSuggestionTarget } from '../core/openaire/reciter-suggestions/models/openaire-suggestion-target.model'; +import { OpenaireSuggestionsDataService } from '../core/suggestion-notifications/reciter-suggestions/openaire-suggestions-data.service'; +import { OpenaireSuggestionTarget } from '../core/suggestion-notifications/reciter-suggestions/models/openaire-suggestion-target.model'; /** * This class represents a resolver that requests a specific collection before the route is activated diff --git a/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts b/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts index 2aaa762b2a..bacf515656 100644 --- a/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts +++ b/src/app/workflowitems-edit-page/item-from-workflow.resolver.ts @@ -1,43 +1,21 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Resolve } from '@angular/router'; import { RemoteData } from '../core/data/remote-data'; import { Item } from '../core/shared/item.model'; -import { followLink } from '../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { Store } from '@ngrx/store'; import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; -import { WorkflowItem } from '../core/submission/models/workflowitem.model'; -import { switchMap } from 'rxjs/operators'; +import { SubmissionObjectResolver } from '../core/submission/resolver/submission-object.resolver'; /** * This class represents a resolver that requests a specific item before the route is activated */ @Injectable() -export class ItemFromWorkflowResolver implements Resolve> { +export class ItemFromWorkflowResolver extends SubmissionObjectResolver implements Resolve> { constructor( private workflowItemService: WorkflowItemDataService, protected store: Store ) { + super(workflowItemService, store); } - /** - * Method for resolving an item based on the parameters in the current route - * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot - * @param {RouterStateSnapshot} state The current RouterStateSnapshot - * @returns Observable<> Emits the found item based on the parameters in the current route, - * or an error if something went wrong - */ - resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - const itemRD$ = this.workflowItemService.findById(route.params.id, - true, - false, - followLink('item'), - ).pipe( - getFirstCompletedRemoteData(), - switchMap((wfiRD: RemoteData) => wfiRD.payload.item as Observable>), - getFirstCompletedRemoteData() - ); - return itemRD$; - } } diff --git a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts new file mode 100644 index 0000000000..c14344d70d --- /dev/null +++ b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.spec.ts @@ -0,0 +1,36 @@ +import { first } from 'rxjs/operators'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { ItemFromWorkspaceResolver } from './item-from-workspace.resolver'; + +describe('ItemFromWorkspaceResolver', () => { + describe('resolve', () => { + let resolver: ItemFromWorkspaceResolver; + let wfiService: WorkspaceitemDataService; + const uuid = '1234-65487-12354-1235'; + const itemUuid = '8888-8888-8888-8888'; + const wfi = { + id: uuid, + item: createSuccessfulRemoteDataObject$({ id: itemUuid }) + }; + + + beforeEach(() => { + wfiService = { + findById: (id: string) => createSuccessfulRemoteDataObject$(wfi) + } as any; + resolver = new ItemFromWorkspaceResolver(wfiService, null); + }); + + it('should resolve a an item from from the workflow item with the correct id', (done) => { + resolver.resolve({ params: { id: uuid } } as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.payload.id).toEqual(itemUuid); + done(); + } + ); + }); + }); +}); diff --git a/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts new file mode 100644 index 0000000000..60e1fe6a87 --- /dev/null +++ b/src/app/workspaceitems-edit-page/item-from-workspace.resolver.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { Item } from '../core/shared/item.model'; +import { Store } from '@ngrx/store'; +import { SubmissionObjectResolver } from '../core/submission/resolver/submission-object.resolver'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class ItemFromWorkspaceResolver extends SubmissionObjectResolver implements Resolve> { + constructor( + private workspaceItemService: WorkspaceitemDataService, + protected store: Store + ) { + super(workspaceItemService, store); + } + +} diff --git a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts new file mode 100644 index 0000000000..bbd3360db4 --- /dev/null +++ b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.spec.ts @@ -0,0 +1,30 @@ +import { first } from 'rxjs/operators'; +import { WorkspaceItemPageResolver } from './workspace-item-page.resolver'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; + +describe('WorkflowItemPageResolver', () => { + describe('resolve', () => { + let resolver: WorkspaceItemPageResolver; + let wsiService: WorkspaceitemDataService; + const uuid = '1234-65487-12354-1235'; + + beforeEach(() => { + wsiService = { + findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) + } as any; + resolver = new WorkspaceItemPageResolver(wsiService); + }); + + it('should resolve a workspace item with the correct id', (done) => { + resolver.resolve({ params: { id: uuid } } as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.payload.id).toEqual(uuid); + done(); + } + ); + }); + }); +}); diff --git a/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts new file mode 100644 index 0000000000..1b1aa25492 --- /dev/null +++ b/src/app/workspaceitems-edit-page/workspace-item-page.resolver.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../core/data/remote-data'; +import { followLink } from '../shared/utils/follow-link-config.model'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * This class represents a resolver that requests a specific workflow item before the route is activated + */ +@Injectable() +export class WorkspaceItemPageResolver implements Resolve> { + constructor(private workspaceItemService: WorkspaceitemDataService) { + } + + /** + * Method for resolving a workflow item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found workflow item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + return this.workspaceItemService.findById(route.params.id, + true, + false, + followLink('item'), + ).pipe( + getFirstCompletedRemoteData(), + ); + } +} diff --git a/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing-paths.ts b/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing-paths.ts new file mode 100644 index 0000000000..74917b4392 --- /dev/null +++ b/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing-paths.ts @@ -0,0 +1,8 @@ +import { getWorkspaceItemModuleRoute } from '../app-routing-paths'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export function getWorkspaceItemViewRoute(wfiId: string) { + return new URLCombiner(getWorkspaceItemModuleRoute(), wfiId, WORKSPACE_ITEM_VIEW_PATH).toString(); +} + +export const WORKSPACE_ITEM_VIEW_PATH = 'view'; diff --git a/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts index 1a58417d0c..cc76634c03 100644 --- a/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts +++ b/src/app/workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -4,22 +4,42 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { ThemedSubmissionEditComponent } from '../submission/edit/themed-submission-edit.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ThemedFullItemPageComponent } from '../item-page/full/themed-full-item-page.component'; +import { ItemFromWorkspaceResolver } from './item-from-workspace.resolver'; +import { WorkspaceItemPageResolver } from './workspace-item-page.resolver'; @NgModule({ imports: [ RouterModule.forChild([ { path: '', redirectTo: '/home', pathMatch: 'full' }, { - canActivate: [AuthenticatedGuard], - path: ':id/edit', - component: ThemedSubmissionEditComponent, - resolve: { - breadcrumb: I18nBreadcrumbResolver - }, - data: { title: 'submission.edit.title', breadcrumbKey: 'submission.edit' } + path: ':id', + resolve: { wsi: WorkspaceItemPageResolver }, + children: [ + { + canActivate: [AuthenticatedGuard], + path: 'edit', + component: ThemedSubmissionEditComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver + }, + data: { title: 'submission.edit.title', breadcrumbKey: 'submission.edit' } + }, + { + canActivate: [AuthenticatedGuard], + path: 'view', + component: ThemedFullItemPageComponent, + resolve: { + dso: ItemFromWorkspaceResolver, + breadcrumb: I18nBreadcrumbResolver + }, + data: { title: 'workspace-item.view.title', breadcrumbKey: 'workspace-item.view' } + } + ] } ]) - ] + ], + providers: [WorkspaceItemPageResolver, ItemFromWorkspaceResolver] }) /** * This module defines the default component to load when navigating to the workspaceitems edit page path diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index 38e7398f46..c3a874535c 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -193,8 +193,8 @@ // "admin.registries.bitstream-formats.table.supportLevel.head": "Support Level", "admin.registries.bitstream-formats.table.supportLevel.head": "Unterstützungsgrad", - // "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry", - "admin.registries.bitstream-formats.title": "DSpace Angular :: Referenzliste der Dateiformate", + // "admin.registries.bitstream-formats.title": "Bitstream Format Registry", + "admin.registries.bitstream-formats.title": "Referenzliste der Dateiformate", @@ -234,8 +234,8 @@ // "admin.registries.metadata.schemas.table.namespace": "Namespace", "admin.registries.metadata.schemas.table.namespace": "Namensraum", - // "admin.registries.metadata.title": "DSpace Angular :: Metadata Registry", - "admin.registries.metadata.title": "DSpace Angular :: Metadatenreferenzliste", + // "admin.registries.metadata.title": "Metadata Registry", + "admin.registries.metadata.title": "Metadatenreferenzliste", @@ -252,7 +252,7 @@ "admin.registries.schema.fields.no-items": "Es gibt keine Metadatenfelder in diesem Schema.", // "admin.registries.schema.fields.table.delete": "Delete selected", - "admin.registries.schema.fields.table.delete": "Ausgewähltes löschen", + "admin.registries.schema.fields.table.delete": "Auswahl löschen", // "admin.registries.schema.fields.table.field": "Field", "admin.registries.schema.fields.table.field": "Feld", @@ -311,8 +311,8 @@ // "admin.registries.schema.return": "Return", "admin.registries.schema.return": "Zurück", - // "admin.registries.schema.title": "DSpace Angular :: Metadata Schema Registry", - "admin.registries.schema.title": "DSpace Angular :: Referenzliste der Metadatenschemata", + // "admin.registries.schema.title": "Metadata Schema Registry", + "admin.registries.schema.title": "Referenzliste der Metadatenschemata", @@ -328,8 +328,8 @@ // "admin.access-control.epeople.actions.stop-impersonating": "Stop impersonating EPerson", "admin.access-control.epeople.actions.stop-impersonating": "Von Person ausloggen", - // "admin.access-control.epeople.title": "DSpace Angular :: EPeople", - "admin.access-control.epeople.title": "DSpace Angular :: Personen", + // "admin.access-control.epeople.title": "EPeople", + "admin.access-control.epeople.title": "Personen", // "admin.access-control.epeople.head": "EPeople", "admin.access-control.epeople.head": "Personen", @@ -444,14 +444,14 @@ - // "admin.access-control.groups.title": "DSpace Angular :: Groups", - "admin.access-control.groups.title": "DSpace Angular :: Gruppen", + // "admin.access-control.groups.title": "Groups", + "admin.access-control.groups.title": "Gruppen", - // "admin.access-control.groups.title.singleGroup": "DSpace Angular :: Edit Group", - "admin.access-control.groups.title.singleGroup": "DSpace Angular :: Gruppe bearbeiten", + // "admin.access-control.groups.title.singleGroup": "Edit Group", + "admin.access-control.groups.title.singleGroup": "Gruppe bearbeiten", - // "admin.access-control.groups.title.addGroup": "DSpace Angular :: New Group", - "admin.access-control.groups.title.addGroup": "DSpace Angular :: Neue Gruppe", + // "admin.access-control.groups.title.addGroup": "New Group", + "admin.access-control.groups.title.addGroup": "Neue Gruppe", // "admin.access-control.groups.head": "Groups", "admin.access-control.groups.head": "Gruppen", @@ -895,6 +895,8 @@ "bitstream.edit.title": "Datei bearbeiten", + // "browse.back.all-results": "All browse results", + "browse.back.all-results": "Filter zurücksetzen", // "browse.comcol.by.author": "By Author", "browse.comcol.by.author": "Nach Autor:in", @@ -1006,9 +1008,12 @@ // "browse.startsWith.type_text": "Or enter first few letters:", "browse.startsWith.type_text": "Oder geben Sie die ersten Buchstaben ein:", - - // "browse.title": "Browsing {{ collection }} by {{ field }} {{ value }}", - "browse.title": "Auflistung von {{ collection }} nach {{ field }} {{ value }}", + + // "browse.title": "Browsing {{ collection }} by {{ field }}{{ startsWith }} {{ value }}", + "browse.title": "Auflistung {{ collection }} nach {{ field }}{{ startsWith }} {{ value }}", + + // "browse.title.page": "Browsing {{ collection }} by {{ field }} {{ value }}", + "browse.title.page": "Auflistung {{ collection }} nach {{ field }} {{ value }}", // "chips.remove": "Remove chip", @@ -1354,9 +1359,12 @@ // "community.edit.logo.delete.title": "Delete logo", "community.edit.logo.delete.title": "Logo löschen", + + // "communityList.breadcrumbs": "Community List", + "communityList.breadcrumbs": "Bereichsliste", - // "communityList.tabTitle": "DSpace - Community List", - "communityList.tabTitle": "DSpace - Bereichsliste", + // "communityList.tabTitle": "Community List", + "communityList.tabTitle": "Bereichsliste", // "communityList.title": "List of Communities", "communityList.title": "Liste der Bereiche", @@ -1373,7 +1381,7 @@ "community.create.notifications.success": "Bereich erfolgreich angelegt", // "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", - "community.create.sub-head": "Teilbeirech im Bereich {{ parent }} anlegen", + "community.create.sub-head": "Teilbereich im Bereich {{ parent }} anlegen", // "community.curate.header": "Curate Community: {{community}}", "community.curate.header": "Bereich: {{community}} pflegen", @@ -1560,7 +1568,7 @@ "community.form.errors.title.required": "Bitte geben Sie einen Namen für den Bereich ein.", // "community.form.rights": "Copyright text (HTML)", - "community.form.rights": "Copyrighterklärung (HTML)", + "community.form.rights": "Copyright Text (HTML)", // "community.form.tableofcontents": "News (HTML)", "community.form.tableofcontents": "Neuigkeiten (HTML)", @@ -1753,7 +1761,7 @@ "dso-selector.create.community.sub-level": "Einen neuen Bereich anlegen in", // "dso-selector.create.community.top-level": "Create a new top-level community", - "dso-selector.create.community.top-level": "Einen neuen Bereich auf oberster Ebene anlgen", + "dso-selector.create.community.top-level": "Einen neuen Bereich auf oberster Ebene anlegen", // "dso-selector.create.item.head": "New item", "dso-selector.create.item.head": "Neues Item", @@ -1782,6 +1790,18 @@ // "dso-selector.placeholder": "Search for a {{ type }}", "dso-selector.placeholder": "Suche nach {{ type }}", + // "dso-selector.select.collection.head": "Select a collection", + "dso-selector.select.collection.head": "Sammlung auswählen", + + // "dso-selector.set-scope.community.head": "Select a search scope", + "dso-selector.set-scope.community.head": "Suchbereich auswählen", + + // "dso-selector.set-scope.community.button": "Search all of DSpace", + "dso-selector.set-scope.community.button": "Alles durchsuchen", + + // "dso-selector.set-scope.community.input-header": "Search for a community or collection", + "dso-selector.set-scope.community.input-header": "Suche Bereich oder Sammlung", + // "confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}", @@ -2127,8 +2147,8 @@ // "home.search-form.placeholder": "Search the repository ...", "home.search-form.placeholder": "Durchsuche Repositorium", - // "home.title": "DSpace Angular :: Home", - "home.title": "DSpace Angular :: Startseite", + // "home.title": "Home", + "home.title": "Startseite", // "home.top-level-communities.head": "Communities in DSpace", "home.top-level-communities.head": "Hauptbereiche in DSpace", @@ -2789,8 +2809,8 @@ // "item.search.results.head": "Item Search Results", "item.search.results.head": "Item-Suchergebnisse", - // "item.search.title": "DSpace Angular :: Item Search", - "item.search.title": "DSpace Angular :: Item-Suche", + // "item.search.title": "Item Search", + "item.search.title": "Item-Suche", @@ -2986,8 +3006,8 @@ // "journal.search.results.head": "Journal Search Results", "journal.search.results.head": "Suchergebnisse für Zeitschriften", - // "journal.search.title": "DSpace Angular :: Journal Search", - "journal.search.title": "DSpace Angular :: Zeitschriftensuche", + // "journal.search.title": "Journal Search", + "journal.search.title": "Zeitschriftensuche", @@ -3615,8 +3635,8 @@ // "person.search.results.head": "Person Search Results", "person.search.results.head": "Ergebnisse der Personensuche", - // "person.search.title": "DSpace Angular :: Person Search", - "person.search.title": "DSpace Angular :: Personensuche", + // "person.search.title": "Person Search", + "person.search.title": "Personensuche", @@ -3908,8 +3928,8 @@ // "publication.search.results.head": "Publication Search Results", "publication.search.results.head": "Suchergebnisse Publikationen", - // "publication.search.title": "DSpace Angular :: Publication Search", - "publication.search.title": "DSpace Angular :: Publikationssuche", + // "publication.search.title": "Publication Search", + "publication.search.title": "Publikationssuche", // "register-email.title": "New user registration", @@ -4067,9 +4087,27 @@ // "relationships.isContributorOf": "Contributors", "relationships.isContributorOf": "Beteiligte", - - - + + // "relationships.isContributorOf.OrgUnit": "Contributor (Organizational Unit)", + "relationships.isContributorOf.OrgUnit": "Beteiligte (Organisationseinheit)", + + // "relationships.isContributorOf.Person": "Contributor", + "relationships.isContributorOf.Person": "Beteiligte (Person)", + + // "relationships.isFundingAgencyOf.OrgUnit": "Funder", + "relationships.isFundingAgencyOf.OrgUnit": "Förderer", + + + // "repository.image.logo": "Repository logo", + "repository.image.logo": "Repository Logo", + + // "repository.title.prefix": "DSpace Angular :: ", + "repository.title.prefix": "DSpace Angular :: ", + + // "repository.title.prefixDSpace": "DSpace Angular ::", + "repository.title.prefixDSpace": "DSpace Angular ::", + + // "resource-policies.add.button": "Add", "resource-policies.add.button": "Hinzufügen", @@ -4231,8 +4269,8 @@ // "search.switch-configuration.title": "Show", "search.switch-configuration.title": "Zeige", - // "search.title": "DSpace Angular :: Search", - "search.title": "DSpace Angular :: Suche", + // "search.title": "Search", + "search.title": "Suche", // "search.breadcrumbs": "Search", "search.breadcrumbs": "Suche", @@ -4293,6 +4331,9 @@ // "search.filters.filter.author.placeholder": "Author name", "search.filters.filter.author.placeholder": "Autor:innenname", + // "search.filters.filter.author.label": "Search author name", + "search.filters.filter.author.label": "Autorensuche", + // "search.filters.filter.birthDate.head": "Birth Date", "search.filters.filter.birthDate.head": "Geburtsdatum", @@ -4472,8 +4513,10 @@ // "search.form.search_mydspace": "Search MyDSpace", "search.form.search_mydspace": "Suche im persönlichen Arbeitsbereich", - - + // "search.form.scope.all": "All of DSpace", + "search.form.scope.all": "Alles durchsuchen", + + // "search.results.head": "Search Results", "search.results.head": "Suchergebnisse", @@ -4486,7 +4529,11 @@ // "search.results.empty": "Your search returned no results.", "search.results.empty": "Ihre Suche führte zu keinem Ergebnis.", - + // "search.results.view-result": "View", + "search.results.view-result": "Anzeigen", + + // "default.search.results.head": "Search Results", + "default.search.results.head": "Suchergebnisse", // "search.sidebar.close": "Back to results", "search.sidebar.close": "Zurück zu den Ergebnissen", @@ -4534,10 +4581,29 @@ // "sorting.dc.title.DESC": "Title Descending", "sorting.dc.title.DESC": "Titel absteigend", - // "sorting.score.DESC": "Relevance", - "sorting.score.DESC": "Relevanz", - + // "sorting.score.ASC": "Least Relevant", + "sorting.score.ASC": "Relevanz aufsteigend", + + // "sorting.score.DESC": "Most Relevant", + "sorting.score.DESC": "Relevanz absteigend", + + // "sorting.dc.date.issued.ASC": "Date Issued Ascending", + "sorting.dc.date.issued.ASC": "Erscheinungsdatum aufsteigend", + + // "sorting.dc.date.issued.DESC": "Date Issued Descending", + "sorting.dc.date.issued.DESC": "Erscheinungsdatum absteigend", + // "sorting.dc.date.accessioned.ASC": "Accessioned Date Ascending", + "sorting.dc.date.accessioned.ASC": "Freischaltungsdatum aufsteigend", + + // "sorting.dc.date.accessioned.DESC": "Accessioned Date Descending", + "sorting.dc.date.accessioned.DESC": "Freischaltungsdatum absteigend", + + // "sorting.lastModified.ASC": "Last modified Ascending", + "sorting.lastModified.ASC": "Änderungsdatum aufsteigend", + + // "sorting.lastModified.DESC": "Last modified Descending", + "sorting.lastModified.DESC": "Änderungsdatum absteigend", // "statistics.title": "Statistics", "statistics.title": "Statistiken", @@ -4573,6 +4639,8 @@ "statistics.table.header.views": "Aufrufe", + // "submission.edit.breadcrumbs": "Edit Submission", + "submission.edit.breadcrumbs": "Einreichung bearbeiten", // "submission.edit.title": "Edit Submission", "submission.edit.title": "Einreichung bearbeiten", @@ -4598,6 +4666,12 @@ // "submission.general.discard.submit": "Discard", "submission.general.discard.submit": "Verwerfen", + // "submission.general.info.saved": "Saved", + "submission.general.info.saved": "Gespeichert", + + // "submission.general.info.pending-changes": "Unsaved changes", + "submission.general.info.pending-changes": "Ungespeicherte Änderungen", + // "submission.general.save": "Save", "submission.general.save": "Speichern", @@ -5157,7 +5231,7 @@ "submission.workflow.generic.delete": "Löschen", // "submission.workflow.generic.delete-help": "If you would to discard this item, select \"Delete\". You will then be asked to confirm it.", - "submission.workflow.generic.delete-help": "Wenn Sie dieses Item möchten, wählen Sie bitte \"Löschen\". Sie werden danach um Bestätigung gebeten.", + "submission.workflow.generic.delete-help": "Wenn Sie dieses Item verwerfen möchten, wählen Sie bitte \"Löschen\". Sie werden danach um Bestätigung gebeten.", // "submission.workflow.generic.edit": "Edit", "submission.workflow.generic.edit": "Bearbeiten", @@ -5237,6 +5311,37 @@ // "submission.workflow.tasks.pool.show-detail": "Show detail", "submission.workflow.tasks.pool.show-detail": "Details anzeigen", + // "submission.workspace.generic.view": "View", + "submission.workspace.generic.view": "Anzeige", + + // "submission.workspace.generic.view-help": "Select this option to view the item's metadata.", + "submission.workspace.generic.view-help": "Wählen Sie diese Option, um die Metadaten des Items anzuzeigen.", + + + + // "thumbnail.default.alt": "Thumbnail Image", + "thumbnail.default.alt": "Vorschaubild", + + // "thumbnail.default.placeholder": "No Thumbnail Available", + "thumbnail.default.placeholder": "Vorschaubild nicht verfügbar", + + // "thumbnail.project.alt": "Project Logo", + "thumbnail.project.alt": "Logo des Projekt", + + // "thumbnail.project.placeholder": "Project Placeholder Image", + "thumbnail.project.placeholder": "Platzhalterbild des Projekts", + + // "thumbnail.orgunit.alt": "OrgUnit Logo", + "thumbnail.orgunit.alt": "Logo der Organisationseinheit", + + // "thumbnail.orgunit.placeholder": "OrgUnit Placeholder Image", + "thumbnail.orgunit.placeholder": "Platzhalterbild der Organisationseinheit", + + // "thumbnail.person.alt": "Profile Picture", + "thumbnail.person.alt": "Profilbild", + + // "thumbnail.person.placeholder": "No Profile Picture Available", + "thumbnail.person.placeholder": "Profilbild nicht verfügbar", // "title": "DSpace", @@ -5292,10 +5397,14 @@ "virtual-metadata.delete-relationship.modal-head": "Wählen Sie die Items aus, deren virtuelle Metadaten Sie als echte Metadaten speichern möchten.", - + // "workspace.search.results.head": "Your submissions", + "workspace.search.results.head": "Ihre Veröffentlichungen", + // "workflowAdmin.search.results.head": "Administer Workflow", "workflowAdmin.search.results.head": "Workflow verwalten", - + + // "workflow.search.results.head": "Workflow tasks", + "workflow.search.results.head": "Workflow-Aufgaben", // "workflow-item.delete.notification.success.title": "Deleted", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 02e61582ca..ac43851040 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -31,6 +31,26 @@ "admin.notifications.recitersuggestion.page.title": "Suggestions", + "error-page.description.401": "unauthorized", + + "error-page.description.403": "forbidden", + + "error-page.description.500": "Service Unavailable", + + "error-page.description.404": "page not found", + + "error-page.orcid.generic-error": "An error occurred during login via ORCID. Make sure you have shared your ORCID account email address with DSpace. If the error persists, contact the administrator", + + "access-status.embargo.listelement.badge": "Embargo", + + "access-status.metadata.only.listelement.badge": "Metadata only", + + "access-status.open.access.listelement.badge": "Open Access", + + "access-status.restricted.listelement.badge": "Restricted", + + "access-status.unknown.listelement.badge": "Unknown", + "admin.curation-tasks.breadcrumbs": "System curation tasks", "admin.curation-tasks.title": "System curation tasks", @@ -483,7 +503,15 @@ "admin.access-control.groups.form.return": "Back", + "admin.quality-assurance.breadcrumbs": "Quality Assurance", + "admin.notifications.event.breadcrumbs": "Quality Assurance Suggestions", + + "admin.notifications.event.page.title": "Quality Assurance Suggestions", + + "admin.quality-assurance.page.title": "Quality Assurance", + + "admin.notifications.source.breadcrumbs": "Quality Assurance Source", "admin.search.breadcrumbs": "Administrative Search", @@ -542,6 +570,10 @@ "admin.metadata-import.page.error.addFile": "Select file first!", + "admin.metadata-import.page.validateOnly": "Validate Only", + + "admin.metadata-import.page.validateOnly.hint": "When selected, the uploaded CSV will be validated. You will receive a report of detected changes, but no changes will be saved.", + @@ -852,6 +884,12 @@ "collection.edit.tabs.authorizations.title": "Collection Edit - Authorizations", + "collection.edit.item.authorizations.load-bundle-button": "Load more bundles", + + "collection.edit.item.authorizations.load-more-button": "Load more", + + "collection.edit.item.authorizations.show-bitstreams-button": "Show bitstream policies for bundle", + "collection.edit.tabs.metadata.head": "Edit Metadata", "collection.edit.tabs.metadata.title": "Collection Edit - Metadata", @@ -1095,10 +1133,14 @@ "comcol-role.edit.create": "Create", + "comcol-role.edit.create.error.title": "Failed to create a group for the '{{ role }}' role", + "comcol-role.edit.restrict": "Restrict", "comcol-role.edit.delete": "Delete", + "comcol-role.edit.delete.error.title": "Failed to delete the '{{ role }}' role's group", + "comcol-role.edit.community-admin.name": "Administrators", @@ -1261,6 +1303,8 @@ "curation.form.submit.error.content": "An error occured when trying to start the curation task.", + "curation.form.submit.error.invalid-handle": "Couldn't determine the handle for this object", + "curation.form.handle.label": "Handle:", "curation.form.handle.hint": "Hint: Enter [your-handle-prefix]/0 to run a task across entire site (not all tasks may support this capability)", @@ -1347,6 +1391,14 @@ "confirmation-modal.delete-eperson.confirm": "Delete", + "confirmation-modal.delete-profile.header": "Delete Profile", + + "confirmation-modal.delete-profile.info": "Are you sure you want to delete your profile", + + "confirmation-modal.delete-profile.cancel": "Cancel", + + "confirmation-modal.delete-profile.confirm": "Delete", + "error.bitstream": "Error fetching bitstream", @@ -1397,6 +1449,9 @@ "error.validation.groupExists": "This group already exists", + "feed.description": "Syndication feed", + + "file-section.error.header": "Error obtaining files for this item", @@ -1576,6 +1631,46 @@ "grant-request-copy.success": "Successfully granted item request", + "health.breadcrumbs": "Health", + + "health-page.heading" : "Health", + + "health-page.info-tab" : "Info", + + "health-page.status-tab" : "Status", + + "health-page.error.msg": "The health check service is temporarily unavailable", + + "health-page.property.status": "Status code", + + "health-page.section.db.title": "Database", + + "health-page.section.geoIp.title": "GeoIp", + + "health-page.section.solrAuthorityCore.title": "Sor: authority core", + + "health-page.section.solrOaiCore.title": "Sor: oai core", + + "health-page.section.solrSearchCore.title": "Sor: search core", + + "health-page.section.solrStatisticsCore.title": "Sor: statistics core", + + "health-page.section-info.app.title": "Application Backend", + + "health-page.section-info.java.title": "Java", + + "health-page.status": "Status", + + "health-page.status.ok.info": "Operational", + + "health-page.status.error.info": "Problems detected", + + "health-page.status.warning.info": "Possible issues detected", + + "health-page.title": "Health", + + "health-page.section.no-issues": "No issues detected", + "home.description": "", @@ -2054,6 +2149,7 @@ "item.edit.withdraw.success": "The item was withdrawn successfully", + "item.orcid.return": "Back", "item.listelement.badge": "Item", @@ -2074,6 +2170,10 @@ "item.search.title": "Item Search", + "item.truncatable-part.show-more": "Show more", + + "item.truncatable-part.show-less": "Collapse", + "item.page.abstract": "Abstract", @@ -2110,6 +2210,10 @@ "item.page.link.simple": "Simple item page", + "item.page.orcid.title": "ORCID", + + "item.page.orcid.tooltip": "Open ORCID setting page", + "item.page.person.search.title": "Articles by this author", "item.page.related-items.view-more": "Show {{ amount }} more", @@ -2144,6 +2248,8 @@ "item.page.claim.button": "Claim", + "item.page.claim.tooltip": "Claim this item as profile", + "item.preview.dc.identifier.uri": "Identifier:", "item.preview.dc.contributor.author": "Authors:", @@ -2160,6 +2266,22 @@ "item.preview.dc.title": "Title:", + "item.preview.dc.type": "Type:", + + "item.preview.oaire.citation.issue" : "Issue", + + "item.preview.oaire.citation.volume" : "Volume", + + "item.preview.dc.relation.issn" : "ISSN", + + "item.preview.dc.identifier.isbn" : "ISBN", + + "item.preview.dc.identifier": "Identifier:", + + "item.preview.dc.relation.ispartof" : "Journal or Serie", + + "item.preview.dc.identifier.doi" : "DOI", + "item.preview.person.familyName": "Surname:", "item.preview.person.givenName": "Name:", @@ -2253,6 +2375,10 @@ "item.version.create.modal.form.summary.placeholder": "Insert the summary for the new version", + "item.version.create.modal.submitted.header": "Creating new version...", + + "item.version.create.modal.submitted.text": "The new version is being created. This may take some time if the item has a lot of relationships.", + "item.version.create.notification.success" : "New version has been created with version number {{version}}", "item.version.create.notification.failure" : "New version has not been created", @@ -2299,6 +2425,8 @@ "journal.search.results.head": "Journal Search Results", + "journal-relationships.search.results.head": "Journal Search Results", + "journal.search.title": "Journal Search", @@ -2409,6 +2537,8 @@ "login.form.oidc": "Log in with OIDC", + "login.form.orcid": "Log in with ORCID", + "login.form.password": "Password", "login.form.shibboleth": "Log in with Shibboleth", @@ -2429,6 +2559,7 @@ + "menu.header.admin": "Management", "menu.header.image.logo": "Repository logo", @@ -2515,13 +2646,15 @@ "menu.section.icon.find": "Find menu section", + "menu.section.icon.health": "Health check menu section", + "menu.section.icon.import": "Import menu section", "menu.section.icon.new": "New menu section", "menu.section.icon.pin": "Pin sidebar", - "menu.section.icon.processes": "Processes menu section", + "menu.section.icon.processes": "Processes Health", "menu.section.icon.registries": "Registries menu section", @@ -2531,8 +2664,7 @@ "menu.section.icon.unpin": "Unpin sidebar", - "menu.section.icon.notifications": "Notifictions menu section", - + "menu.section.icon.notifications": "Notifications menu section", "menu.section.import": "Import", @@ -2559,6 +2691,12 @@ "menu.section.notifications_reciter": "Publication Claim", + "menu.section.notifications": "Notifications", + + "menu.section.quality-assurance": "Quality Assurance", + + "menu.section.notifications_reciter": "Publication Claim", + "menu.section.pin": "Pin sidebar", @@ -2568,6 +2706,8 @@ "menu.section.processes": "Processes", + "menu.section.health": "Health", + "menu.section.registries": "Registries", @@ -2608,6 +2748,11 @@ "menu.section.workflow": "Administer Workflow", + "metadata-export-search.tooltip": "Export search results as CSV", + "metadata-export-search.submit.success": "The export was started successfully", + "metadata-export-search.submit.error": "Starting the export has failed", + + "mydspace.breadcrumbs": "MyDSpace", "mydspace.description": "", @@ -2724,6 +2869,136 @@ "none.listelement.badge": "Item", + "quality-assurance.title": "Quality Assurance", + + "quality-assurance.topics.description": "Below you can see all the topics received from the subscriptions to {{source}}.", + + "quality-assurance.source.description": "Below you can see all the notification's sources.", + + "quality-assurance.topics": "Current Topics", + + "quality-assurance.source": "Current Sources", + + "quality-assurance.table.topic": "Topic", + + "quality-assurance.table.source": "Source", + + "quality-assurance.table.last-event": "Last Event", + + "quality-assurance.table.actions": "Actions", + + "quality-assurance.button.detail": "Show details", + + "quality-assurance.noTopics": "No topics found.", + + "quality-assurance.noSource": "No sources found.", + + "notifications.events.title": "Quality Assurance Suggestions", + + "quality-assurance.topic.error.service.retrieve": "An error occurred while loading the Quality Assurance topics", + + "quality-assurance.source.error.service.retrieve": "An error occurred while loading the Quality Assurance source", + + "quality-assurance.events.description": "Below the list of all the suggestions for the selected topic.", + + "quality-assurance.loading": "Loading ...", + + "quality-assurance.events.topic": "Topic:", + + "quality-assurance.noEvents": "No suggestions found.", + + "quality-assurance.event.table.trust": "Trust", + + "quality-assurance.event.table.publication": "Publication", + + "quality-assurance.event.table.details": "Details", + + "quality-assurance.event.table.project-details": "Project details", + + "quality-assurance.event.table.actions": "Actions", + + "quality-assurance.event.action.accept": "Accept suggestion", + + "quality-assurance.event.action.ignore": "Ignore suggestion", + + "quality-assurance.event.action.reject": "Reject suggestion", + + "quality-assurance.event.action.import": "Import project and accept suggestion", + + "quality-assurance.event.table.pidtype": "PID Type:", + + "quality-assurance.event.table.pidvalue": "PID Value:", + + "quality-assurance.event.table.subjectValue": "Subject Value:", + + "quality-assurance.event.table.abstract": "Abstract:", + + "quality-assurance.event.table.suggestedProject": "OpenAIRE Suggested Project data", + + "quality-assurance.event.table.project": "Project title:", + + "quality-assurance.event.table.acronym": "Acronym:", + + "quality-assurance.event.table.code": "Code:", + + "quality-assurance.event.table.funder": "Funder:", + + "quality-assurance.event.table.fundingProgram": "Funding program:", + + "quality-assurance.event.table.jurisdiction": "Jurisdiction:", + + "quality-assurance.events.back": "Back to topics", + + "quality-assurance.event.table.less": "Show less", + + "quality-assurance.event.table.more": "Show more", + + "quality-assurance.event.project.found": "Bound to the local record:", + + "quality-assurance.event.project.notFound": "No local record found", + + "quality-assurance.event.sure": "Are you sure?", + + "quality-assurance.event.ignore.description": "This operation can't be undone. Ignore this suggestion?", + + "quality-assurance.event.reject.description": "This operation can't be undone. Reject this suggestion?", + + "quality-assurance.event.accept.description": "No DSpace project selected. A new project will be created based on the suggestion data.", + + "quality-assurance.event.action.cancel": "Cancel", + + "quality-assurance.event.action.saved": "Your decision has been saved successfully.", + + "quality-assurance.event.action.error": "An error has occurred. Your decision has not been saved.", + + "quality-assurance.event.modal.project.title": "Choose a project to bound", + + "quality-assurance.event.modal.project.publication": "Publication:", + + "quality-assurance.event.modal.project.bountToLocal": "Bound to the local record:", + + "quality-assurance.event.modal.project.select": "Project search", + + "quality-assurance.event.modal.project.search": "Search", + + "quality-assurance.event.modal.project.clear": "Clear", + + "quality-assurance.event.modal.project.cancel": "Cancel", + + "quality-assurance.event.modal.project.bound": "Bound project", + + "quality-assurance.event.modal.project.placeholder": "Enter a project name", + + "quality-assurance.event.modal.project.notFound": "No project found.", + + "quality-assurance.event.project.bounded": "The project has been linked successfully.", + + "quality-assurance.event.project.removed": "The project has been successfully unlinked.", + + "quality-assurance.event.project.error": "An error has occurred. No operation performed.", + + "quality-assurance.event.reason": "Reason", + "orgunit.listelement.badge": "Organizational Unit", @@ -2771,6 +3046,8 @@ "person.page.lastname": "Last Name", + "person.page.name": "Name", + "person.page.link.full": "Show all metadata", "person.page.orcid": "ORCID", @@ -2781,6 +3058,8 @@ "person.search.results.head": "Person Search Results", + "person-relationships.search.results.head": "Person Search Results", + "person.search.title": "Person Search", @@ -2886,6 +3165,8 @@ "profile.groups.head": "Authorization groups you belong to", + "profile.special.groups.head": "Authorization special groups you belong to", + "profile.head": "Update Profile", "profile.metadata.form.error.firstname.required": "First Name is required", @@ -2956,6 +3237,8 @@ "project.search.results.head": "Project Search Results", + "project-relationships.search.results.head": "Project Search Results", + "publication.listelement.badge": "Publication", @@ -2976,6 +3259,8 @@ "publication.search.results.head": "Publication Search Results", + "publication-relationships.search.results.head": "Publication Search Results", + "publication.search.title": "Publication Search", @@ -3196,6 +3481,10 @@ "resource-policies.edit.page.failure.content": "An error occurred while editing the resource policy.", + "resource-policies.edit.page.target-failure.content": "An error occurred while editing the target (ePerson or group) of the resource policy.", + + "resource-policies.edit.page.other-failure.content": "An error occurred while editing the resource policy. The target (ePerson or group) has been successfully updated.", + "resource-policies.edit.page.success.content": "Operation successful", "resource-policies.edit.page.title": "Edit resource policy", @@ -3218,6 +3507,16 @@ "resource-policies.form.eperson-group-list.table.headers.name": "Name", + "resource-policies.form.eperson-group-list.modal.header": "Cannot change type", + + "resource-policies.form.eperson-group-list.modal.text1.toGroup": "It is not possible to replace an ePerson with a group.", + + "resource-policies.form.eperson-group-list.modal.text1.toEPerson": "It is not possible to replace a group with an ePerson.", + + "resource-policies.form.eperson-group-list.modal.text2": "Delete the current resource policy and create a new one with the desired type.", + + "resource-policies.form.eperson-group-list.modal.close": "Ok", + "resource-policies.form.date.end.label": "End Date", "resource-policies.form.date.start.label": "Start Date", @@ -3445,7 +3744,9 @@ "search.filters.filter.submitter.label": "Search submitter", + "search.filters.filter.funding.head": "Funding", + "search.filters.filter.funding.placeholder": "Funding", "search.filters.entityType.JournalIssue": "Journal Issue", @@ -3495,6 +3796,8 @@ "default.search.results.head": "Search Results", + "default-relationships.search.results.head": "Search Results", + "search.sidebar.close": "Back to results", @@ -3632,6 +3935,24 @@ "submission.import-external.source.arxiv": "arXiv", + "submission.import-external.source.ads": "NASA/ADS", + + "submission.import-external.source.cinii": "CiNii", + + "submission.import-external.source.crossref": "CrossRef", + + "submission.import-external.source.scielo": "SciELO", + + "submission.import-external.source.scopus": "Scopus", + + "submission.import-external.source.vufind": "VuFind", + + "submission.import-external.source.wos": "Web Of Science", + + "submission.import-external.source.orcidWorks": "ORCID", + + "submission.import-external.source.epo": "European Patent Office (EPO)", + "submission.import-external.source.loading": "Loading ...", "submission.import-external.source.sherpaJournal": "SHERPA Journals", @@ -3646,10 +3967,24 @@ "submission.import-external.source.pubmed": "Pubmed", + "submission.import-external.source.pubmedeu": "Pubmed Europe", + "submission.import-external.source.lcname": "Library of Congress Names", "submission.import-external.preview.title": "Item Preview", + "submission.import-external.preview.title.Publication": "Publication Preview", + + "submission.import-external.preview.title.none": "Item Preview", + + "submission.import-external.preview.title.Journal": "Journal Preview", + + "submission.import-external.preview.title.OrgUnit": "Organizational Unit Preview", + + "submission.import-external.preview.title.Person": "Person Preview", + + "submission.import-external.preview.title.Project": "Project Preview", + "submission.import-external.preview.subtitle": "The metadata below was imported from an external source. It will be pre-filled when you start the submission.", "submission.import-external.preview.button.import": "Start submission", @@ -3672,6 +4007,26 @@ "submission.sections.describe.relationship-lookup.external-source.import-button-title.isProjectOfPublication": "Project", + "submission.sections.describe.relationship-lookup.external-source.import-button-title.none": "Import remote item", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Event": "Import remote event", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Product": "Import remote product", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Equipment": "Import remote equipment", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.OrgUnit": "Import remote organizational unit", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Funding": "Import remote fund", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Person": "Import remote person", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Patent": "Import remote patent", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Project": "Import remote project", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Publication": "Import remote publication", + "submission.sections.describe.relationship-lookup.external-source.import-modal.isProjectOfPublication.added.new-entity": "New Entity Added!", "submission.sections.describe.relationship-lookup.external-source.import-modal.isProjectOfPublication.title": "Project", @@ -3888,6 +4243,18 @@ "submission.sections.describe.relationship-lookup.selection-tab.title.arxiv": "Search Results", + "submission.sections.describe.relationship-lookup.selection-tab.title.crossref": "Search Results", + + "submission.sections.describe.relationship-lookup.selection-tab.title.epo": "Search Results", + + "submission.sections.describe.relationship-lookup.selection-tab.title.scopus": "Search Results", + + "submission.sections.describe.relationship-lookup.selection-tab.title.scielo": "Search Results", + + "submission.sections.describe.relationship-lookup.selection-tab.title.wos": "Search Results", + + "submission.sections.describe.relationship-lookup.selection-tab.title": "Search Results", + "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Would you like to save \"{{ value }}\" as a name variant for this person so you and others can reuse it for future submissions? If you don\'t you can still use it for this submission.", "submission.sections.describe.relationship-lookup.name-variant.notification.confirm": "Save a new name variant", @@ -3956,10 +4323,15 @@ "submission.sections.submit.progressbar.license": "Deposit license", + "submission.sections.submit.progressbar.sherpapolicy": "Sherpa policies", + "submission.sections.submit.progressbar.upload": "Upload files", + "submission.sections.submit.progressbar.sherpaPolicies": "Publisher open access policy information", + "submission.sections.sherpa-policy.title-empty": "No publisher policy information available. If your work has an associated ISSN, please enter it above to see any related publisher open access policies.", + "submission.sections.status.errors.title": "Errors", "submission.sections.status.valid.title": "Valid", @@ -3972,6 +4344,10 @@ "submission.sections.status.warnings.aria": "has warnings", + "submission.sections.status.info.title": "Additional Information", + + "submission.sections.status.info.aria": "Additional Information", + "submission.sections.toggle.open": "Open section", "submission.sections.toggle.close": "Close section", @@ -4071,6 +4447,60 @@ "submission.sections.accesses.form.until-placeholder": "Until", + "submission.sections.sherpa.publication.information": "Publication information", + + "submission.sections.sherpa.publication.information.title": "Title", + + "submission.sections.sherpa.publication.information.issns": "ISSNs", + + "submission.sections.sherpa.publication.information.url": "URL", + + "submission.sections.sherpa.publication.information.publishers": "Publisher", + + "submission.sections.sherpa.publication.information.romeoPub": "Romeo Pub", + + "submission.sections.sherpa.publication.information.zetoPub": "Zeto Pub", + + "submission.sections.sherpa.publisher.policy": "Publisher Policy", + + "submission.sections.sherpa.publisher.policy.description": "The below information was found via Sherpa Romeo. Based on the policies of your publisher, it provides advice regarding whether an embargo may be necessary and/or which files you are allowed to upload. If you have questions, please contact your site administrator via the feedback form in the footer.", + + "submission.sections.sherpa.publisher.policy.openaccess": "Open Access pathways permitted by this journal's policy are listed below by article version. Click on a pathway for a more detailed view", + + "submission.sections.sherpa.publisher.policy.more.information": "For more information, please see the following links:", + + "submission.sections.sherpa.publisher.policy.version": "Version", + + "submission.sections.sherpa.publisher.policy.embargo": "Embargo", + + "submission.sections.sherpa.publisher.policy.noembargo": "No Embargo", + + "submission.sections.sherpa.publisher.policy.nolocation": "None", + + "submission.sections.sherpa.publisher.policy.license": "License", + + "submission.sections.sherpa.publisher.policy.prerequisites": "Prerequisites", + + "submission.sections.sherpa.publisher.policy.location": "Location", + + "submission.sections.sherpa.publisher.policy.conditions": "Conditions", + + "submission.sections.sherpa.publisher.policy.refresh": "Refresh", + + "submission.sections.sherpa.record.information": "Record Information", + + "submission.sections.sherpa.record.information.id": "ID", + + "submission.sections.sherpa.record.information.date.created": "Date Created", + + "submission.sections.sherpa.record.information.date.modified": "Last Modified", + + "submission.sections.sherpa.record.information.uri": "URI", + + "submission.sections.sherpa.error.message": "There was an error retrieving sherpa informations", + + + "submission.submit.breadcrumbs": "New submission", "submission.submit.title": "New submission", @@ -4136,6 +4566,10 @@ "submission.workflow.tasks.pool.show-detail": "Show detail", + "submission.workspace.generic.view": "View", + + "submission.workspace.generic.view-help": "Select this option to view the item's metadata.", + "thumbnail.default.alt": "Thumbnail Image", @@ -4242,6 +4676,9 @@ "workflow-item.view.breadcrumbs": "Workflow View", + "workspace-item.view.breadcrumbs": "Workspace View", + + "workspace-item.view.title": "Workspace View", "idle-modal.header": "Session will expire soon", @@ -4255,6 +4692,8 @@ "researcher.profile.associated": "Researcher profile associated", + "researcher.profile.change-visibility.fail": "An unexpected error occurs while changing the profile visibility", + "researcher.profile.create.new": "Create new", "researcher.profile.create.success": "Researcher profile created successfully", @@ -4286,4 +4725,211 @@ "researcherprofile.success.claim.body" : "Profile claimed with success", "researcherprofile.success.claim.title" : "Success", + + "person.page.orcid.create": "Create an ORCID ID", + + "person.page.orcid.granted-authorizations": "Granted authorizations", + + "person.page.orcid.grant-authorizations" : "Grant authorizations", + + "person.page.orcid.link": "Connect to ORCID ID", + + "person.page.orcid.link.processing": "Linking profile to ORCID...", + + "person.page.orcid.link.error.message": "Something went wrong while connecting the profile with ORCID. If the problem persists, contact the administrator.", + + "person.page.orcid.orcid-not-linked-message": "The ORCID iD of this profile ({{ orcid }}) has not yet been connected to an account on the ORCID registry or the connection is expired.", + + "person.page.orcid.unlink": "Disconnect from ORCID", + + "person.page.orcid.unlink.processing": "Processing...", + + "person.page.orcid.missing-authorizations": "Missing authorizations", + + "person.page.orcid.missing-authorizations-message": "The following authorizations are missing:", + + "person.page.orcid.no-missing-authorizations-message": "Great! This box is empty, so you have granted all access rights to use all functions offers by your institution.", + + "person.page.orcid.no-orcid-message": "No ORCID iD associated yet. By clicking on the button below it is possible to link this profile with an ORCID account.", + + "person.page.orcid.profile-preferences": "Profile preferences", + + "person.page.orcid.funding-preferences": "Funding preferences", + + "person.page.orcid.publications-preferences": "Publication preferences", + + "person.page.orcid.remove-orcid-message": "If you need to remove your ORCID, please contact the repository administrator", + + "person.page.orcid.save.preference.changes": "Update settings", + + "person.page.orcid.sync-profile.affiliation" : "Affiliation", + + "person.page.orcid.sync-profile.biographical" : "Biographical data", + + "person.page.orcid.sync-profile.education" : "Education", + + "person.page.orcid.sync-profile.identifiers" : "Identifiers", + + "person.page.orcid.sync-fundings.all" : "All fundings", + + "person.page.orcid.sync-fundings.mine" : "My fundings", + + "person.page.orcid.sync-fundings.my_selected" : "Selected fundings", + + "person.page.orcid.sync-fundings.disabled" : "Disabled", + + "person.page.orcid.sync-publications.all" : "All publications", + + "person.page.orcid.sync-publications.mine" : "My publications", + + "person.page.orcid.sync-publications.my_selected" : "Selected publications", + + "person.page.orcid.sync-publications.disabled" : "Disabled", + + "person.page.orcid.sync-queue.discard" : "Discard the change and do not synchronize with the ORCID registry", + + "person.page.orcid.sync-queue.discard.error": "The discarding of the ORCID queue record failed", + + "person.page.orcid.sync-queue.discard.success": "The ORCID queue record have been discarded successfully", + + "person.page.orcid.sync-queue.empty-message": "The ORCID queue registry is empty", + + "person.page.orcid.sync-queue.table.header.type" : "Type", + + "person.page.orcid.sync-queue.table.header.description" : "Description", + + "person.page.orcid.sync-queue.table.header.action" : "Action", + + "person.page.orcid.sync-queue.description.affiliation": "Affiliations", + + "person.page.orcid.sync-queue.description.country": "Country", + + "person.page.orcid.sync-queue.description.education": "Educations", + + "person.page.orcid.sync-queue.description.external_ids": "External ids", + + "person.page.orcid.sync-queue.description.other_names": "Other names", + + "person.page.orcid.sync-queue.description.qualification": "Qualifications", + + "person.page.orcid.sync-queue.description.researcher_urls": "Researcher urls", + + "person.page.orcid.sync-queue.description.keywords": "Keywords", + + "person.page.orcid.sync-queue.tooltip.insert": "Add a new entry in the ORCID registry", + + "person.page.orcid.sync-queue.tooltip.update": "Update this entry on the ORCID registry", + + "person.page.orcid.sync-queue.tooltip.delete": "Remove this entry from the ORCID registry", + + "person.page.orcid.sync-queue.tooltip.publication": "Publication", + + "person.page.orcid.sync-queue.tooltip.project": "Project", + + "person.page.orcid.sync-queue.tooltip.affiliation": "Affiliation", + + "person.page.orcid.sync-queue.tooltip.education": "Education", + + "person.page.orcid.sync-queue.tooltip.qualification": "Qualification", + + "person.page.orcid.sync-queue.tooltip.other_names": "Other name", + + "person.page.orcid.sync-queue.tooltip.country": "Country", + + "person.page.orcid.sync-queue.tooltip.keywords": "Keyword", + + "person.page.orcid.sync-queue.tooltip.external_ids": "External identifier", + + "person.page.orcid.sync-queue.tooltip.researcher_urls": "Researcher url", + + "person.page.orcid.sync-queue.send" : "Synchronize with ORCID registry", + + "person.page.orcid.sync-queue.send.unauthorized-error.title": "The submission to ORCID failed for missing authorizations.", + + "person.page.orcid.sync-queue.send.unauthorized-error.content": "Click here to grant again the required permissions. If the problem persists, contact the administrator", + + "person.page.orcid.sync-queue.send.bad-request-error": "The submission to ORCID failed because the resource sent to ORCID registry is not valid", + + "person.page.orcid.sync-queue.send.error": "The submission to ORCID failed", + + "person.page.orcid.sync-queue.send.conflict-error": "The submission to ORCID failed because the resource is already present on the ORCID registry", + + "person.page.orcid.sync-queue.send.not-found-warning": "The resource does not exists anymore on the ORCID registry.", + + "person.page.orcid.sync-queue.send.success": "The submission to ORCID was completed successfully", + + "person.page.orcid.sync-queue.send.validation-error": "The data that you want to synchronize with ORCID is not valid", + + "person.page.orcid.sync-queue.send.validation-error.amount-currency.required": "The amount's currency is required", + + "person.page.orcid.sync-queue.send.validation-error.external-id.required": "The resource to be sent requires at least one identifier", + + "person.page.orcid.sync-queue.send.validation-error.title.required": "The title is required", + + "person.page.orcid.sync-queue.send.validation-error.type.required": "The dc.type is required", + + "person.page.orcid.sync-queue.send.validation-error.start-date.required": "The start date is required", + + "person.page.orcid.sync-queue.send.validation-error.funder.required": "The funder is required", + + "person.page.orcid.sync-queue.send.validation-error.country.invalid": "Invalid 2 digits ISO 3166 country", + + "person.page.orcid.sync-queue.send.validation-error.organization.required": "The organization is required", + + "person.page.orcid.sync-queue.send.validation-error.organization.name-required": "The organization's name is required", + + "person.page.orcid.sync-queue.send.validation-error.publication.date-invalid" : "The publication date must be one year after 1900", + + "person.page.orcid.sync-queue.send.validation-error.organization.address-required": "The organization to be sent requires an address", + + "person.page.orcid.sync-queue.send.validation-error.organization.city-required": "The address of the organization to be sent requires a city", + + "person.page.orcid.sync-queue.send.validation-error.organization.country-required": "The address of the organization to be sent requires a valid 2 digits ISO 3166 country", + + "person.page.orcid.sync-queue.send.validation-error.disambiguated-organization.required": "An identifier to disambiguate organizations is required. Supported ids are GRID, Ringgold, Legal Entity identifiers (LEIs) and Crossref Funder Registry identifiers", + + "person.page.orcid.sync-queue.send.validation-error.disambiguated-organization.value-required": "The organization's identifiers requires a value", + + "person.page.orcid.sync-queue.send.validation-error.disambiguation-source.required": "The organization's identifiers requires a source", + + "person.page.orcid.sync-queue.send.validation-error.disambiguation-source.invalid": "The source of one of the organization identifiers is invalid. Supported sources are RINGGOLD, GRID, LEI and FUNDREF", + + "person.page.orcid.synchronization-mode": "Synchronization mode", + + "person.page.orcid.synchronization-mode.batch": "Batch", + + "person.page.orcid.synchronization-mode.label": "Synchronization mode", + + "person.page.orcid.synchronization-mode-message": "Please select how you would like synchronization to ORCID to occur. The options include \"Manual\" (you must send your data to ORCID manually), or \"Batch\" (the system will send your data to ORCID via a scheduled script).", + + "person.page.orcid.synchronization-mode-funding-message": "Select whether to send your linked Project entities to your ORCID record's list of funding information.", + + "person.page.orcid.synchronization-mode-publication-message": "Select whether to send your linked Publication entities to your ORCID record's list of works.", + + "person.page.orcid.synchronization-mode-profile-message": "Select whether to send your biographical data or personal identifiers to your ORCID record.", + + "person.page.orcid.synchronization-settings-update.success": "The synchronization settings have been updated successfully", + + "person.page.orcid.synchronization-settings-update.error": "The update of the synchronization settings failed", + + "person.page.orcid.synchronization-mode.manual": "Manual", + + "person.page.orcid.scope.authenticate": "Get your ORCID iD", + + "person.page.orcid.scope.read-limited": "Read your information with visibility set to Trusted Parties", + + "person.page.orcid.scope.activities-update": "Add/update your research activities", + + "person.page.orcid.scope.person-update": "Add/update other information about you", + + "person.page.orcid.unlink.success": "The disconnection between the profile and the ORCID registry was successful", + + "person.page.orcid.unlink.error": "An error occurred while disconnecting between the profile and the ORCID registry. Try again", + + "person.orcid.sync.setting": "ORCID Synchronization settings", + + "person.orcid.registry.queue": "ORCID Registry Queue", + + "person.orcid.registry.auth": "ORCID Authorizations", + } diff --git a/src/assets/i18n/fi.json5 b/src/assets/i18n/fi.json5 index 02f020a45d..860062fa67 100644 --- a/src/assets/i18n/fi.json5 +++ b/src/assets/i18n/fi.json5 @@ -107,7 +107,7 @@ "admin.registries.bitstream-formats.edit.head": "Tiedostoformaatti: {{ format }}", // "admin.registries.bitstream-formats.edit.internal.hint": "Formats marked as internal are hidden from the user, and used for administrative purposes.", - "admin.registries.bitstream-formats.edit.internal.hint": "Sisäisiksi merkittyjä formaatteja käytetään hallinnollisiin tarkoituksiin, ja ne on piilotettu käyttäjiltä.", + "admin.registries.bitstream-formats.edit.internal.hint": "Sisäisiksi merkittyjä formaatteja käytetään ylläpitotarkoituksiin, ja ne on piilotettu käyttäjiltä.", // "admin.registries.bitstream-formats.edit.internal.label": "Internal", "admin.registries.bitstream-formats.edit.internal.label": "Sisäinen", @@ -662,7 +662,7 @@ // "admin.search.breadcrumbs": "Administrative Search", - "admin.search.breadcrumbs": "Hallinnollinen haku", + "admin.search.breadcrumbs": "Ylläpitäjän haku", // "admin.search.collection.edit": "Edit", "admin.search.collection.edit": "Muokkaa", @@ -692,19 +692,19 @@ "admin.search.item.withdraw": "Poista käytöstä", // "admin.search.title": "Administrative Search", - "admin.search.title": "Hallinnollinen haku", + "admin.search.title": "Ylläpitäjän haku", // "administrativeView.search.results.head": "Administrative Search", - "administrativeView.search.results.head": "Hallinnollinen haku", + "administrativeView.search.results.head": "Ylläpitäjän haku", // "admin.workflow.breadcrumbs": "Administer Workflow", - "admin.workflow.breadcrumbs": "Hallinnointityönkulku", + "admin.workflow.breadcrumbs": "Hallinnoi työnkulkua", // "admin.workflow.title": "Administer Workflow", - "admin.workflow.title": "Hallinnointityönkulku", + "admin.workflow.title": "Hallinnoi työnkulkua", // "admin.workflow.item.workflow": "Workflow", "admin.workflow.item.workflow": "Työnkulku", @@ -2954,7 +2954,7 @@ // "menu.section.admin_search": "Admin Search", - "menu.section.admin_search": "Admin-haku", + "menu.section.admin_search": "Ylläpitäjän haku", @@ -3033,7 +3033,7 @@ "menu.section.icon.access_control": "Pääsyoikeudet", // "menu.section.icon.admin_search": "Admin search menu section", - "menu.section.icon.admin_search": "Admin-haku", + "menu.section.icon.admin_search": "Ylläpitäjän haku", // "menu.section.icon.control_panel": "Control Panel menu section", "menu.section.icon.control_panel": "Hallintapaneeli", @@ -3168,7 +3168,7 @@ // "menu.section.workflow": "Administer Workflow", - "menu.section.workflow": "Hallinnointityönkulku", + "menu.section.workflow": "Hallinnoi työnkulkua", // "mydspace.description": "", @@ -5079,7 +5079,7 @@ // "workflowAdmin.search.results.head": "Administer Workflow", - "workflowAdmin.search.results.head": "Hallinnointityönkulku", + "workflowAdmin.search.results.head": "Hallinnoi työnkulkua", diff --git a/src/assets/i18n/fr.json5 b/src/assets/i18n/fr.json5 index 0f14b78341..3b9d7498be 100644 --- a/src/assets/i18n/fr.json5 +++ b/src/assets/i18n/fr.json5 @@ -36,6 +36,21 @@ // "404.page-not-found": "page not found", "404.page-not-found": "Page introuvable", + // "access-status.embargo.listelement.badge": "Embargo", + "access-status.embargo.listelement.badge": "Restriction temporaire", + + // "access-status.metadata.only.listelement.badge": "Metadata only", + "access-status.metadata.only.listelement.badge": "Métadonnées seulement", + + // "access-status.open.access.listelement.badge": "Open Access", + "access-status.open.access.listelement.badge": "Accès libre", + + // "access-status.restricted.listelement.badge": "Restricted", + "access-status.restricted.listelement.badge": "Restreint", + + // "access-status.unknown.listelement.badge": "Unknown", + "access-status.unknown.listelement.badge": "Inconnu", + // "admin.curation-tasks.breadcrumbs": "System curation tasks", "admin.curation-tasks.breadcrumbs": "Tâches de conservation système", diff --git a/src/assets/i18n/lv.json5 b/src/assets/i18n/lv.json5 index 738baca186..d6c409b783 100644 --- a/src/assets/i18n/lv.json5 +++ b/src/assets/i18n/lv.json5 @@ -1,5625 +1,5625 @@ { - + // "401.help": "You're not authorized to access this page. You can use the button below to get back to the home page.", // TODO New key - Add a translation "401.help": "You're not authorized to access this page. You can use the button below to get back to the home page.", - + // "401.link.home-page": "Take me to the home page", // TODO New key - Add a translation "401.link.home-page": "Take me to the home page", - + // "401.unauthorized": "unauthorized", // TODO New key - Add a translation "401.unauthorized": "unauthorized", - - - + + + // "403.help": "You don't have permission to access this page. You can use the button below to get back to the home page.", // TODO New key - Add a translation "403.help": "You don't have permission to access this page. You can use the button below to get back to the home page.", - + // "403.link.home-page": "Take me to the home page", // TODO New key - Add a translation "403.link.home-page": "Take me to the home page", - + // "403.forbidden": "forbidden", // TODO New key - Add a translation "403.forbidden": "forbidden", - - - + + + // "404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ", "404.help": "Meklēto lapu nav iespējams atrast. Iespējams, lapa ir pārvietota vai izdzēsta. Lai atgrieztos sākumlapā, varat izmantot zemāk esošo pogu. ", - + // "404.link.home-page": "Take me to the home page", "404.link.home-page": "Argriezties uz sākumu", - + // "404.page-not-found": "page not found", "404.page-not-found": "lapa nav atrasta", - + // "admin.curation-tasks.breadcrumbs": "System curation tasks", // TODO New key - Add a translation "admin.curation-tasks.breadcrumbs": "System curation tasks", - + // "admin.curation-tasks.title": "System curation tasks", // TODO New key - Add a translation "admin.curation-tasks.title": "System curation tasks", - + // "admin.curation-tasks.header": "System curation tasks", // TODO New key - Add a translation "admin.curation-tasks.header": "System curation tasks", - + // "admin.registries.bitstream-formats.breadcrumbs": "Format registry", // TODO New key - Add a translation "admin.registries.bitstream-formats.breadcrumbs": "Format registry", - + // "admin.registries.bitstream-formats.create.breadcrumbs": "Bitstream format", // TODO New key - Add a translation "admin.registries.bitstream-formats.create.breadcrumbs": "Bitstream format", - + // "admin.registries.bitstream-formats.create.failure.content": "An error occurred while creating the new bitstream format.", "admin.registries.bitstream-formats.create.failure.content": "Veidojot jauno bitu straumes formātu, radās kļūda.", - + // "admin.registries.bitstream-formats.create.failure.head": "Failure", "admin.registries.bitstream-formats.create.failure.head": "Kļūda", - + // "admin.registries.bitstream-formats.create.head": "Create Bitstream format", "admin.registries.bitstream-formats.create.head": "Izveidot Bitu straumes formātu", - + // "admin.registries.bitstream-formats.create.new": "Add a new bitstream format", "admin.registries.bitstream-formats.create.new": "Pievienot jaunu bitu straumes formātu", - + // "admin.registries.bitstream-formats.create.success.content": "The new bitstream format was successfully created.", "admin.registries.bitstream-formats.create.success.content": "Jauns bitu straumes formāts tika veiksmīgi izveidots.", - + // "admin.registries.bitstream-formats.create.success.head": "Success", "admin.registries.bitstream-formats.create.success.head": "Veiksmīgi izpildīts", - + // "admin.registries.bitstream-formats.delete.failure.amount": "Failed to remove {{ amount }} format(s)", "admin.registries.bitstream-formats.delete.failure.amount": "Neizdevās dzēst {{ amount }} formātu(s)", - + // "admin.registries.bitstream-formats.delete.failure.head": "Failure", "admin.registries.bitstream-formats.delete.failure.head": "Kļūda", - + // "admin.registries.bitstream-formats.delete.success.amount": "Successfully removed {{ amount }} format(s)", "admin.registries.bitstream-formats.delete.success.amount": "Veiksmīgi dzēsti {{ amount }} formats(i)", - + // "admin.registries.bitstream-formats.delete.success.head": "Success", "admin.registries.bitstream-formats.delete.success.head": "Veiksmīgi izpildīts", - + // "admin.registries.bitstream-formats.description": "This list of bitstream formats provides information about known formats and their support level.", "admin.registries.bitstream-formats.description": "Šis bitu straumes formātu saraksts sniedz informāciju par zināmajiem formātiem un to atbalsta līmeni.", - + // "admin.registries.bitstream-formats.edit.breadcrumbs": "Bitstream format", // TODO New key - Add a translation "admin.registries.bitstream-formats.edit.breadcrumbs": "Bitstream format", - + // "admin.registries.bitstream-formats.edit.description.hint": "", "admin.registries.bitstream-formats.edit.description.hint": "", - + // "admin.registries.bitstream-formats.edit.description.label": "Description", "admin.registries.bitstream-formats.edit.description.label": "Apraksts", - + // "admin.registries.bitstream-formats.edit.extensions.hint": "Extensions are file extensions that are used to automatically identify the format of uploaded files. You can enter several extensions for each format.", "admin.registries.bitstream-formats.edit.extensions.hint": "Paplašinājumi ir failu paplašinājumi, kurus izmanto, lai automātiski identificētu augšupielādēto failu formātu. Katram formātam var ievadīt vairākus paplašinājumus.", - + // "admin.registries.bitstream-formats.edit.extensions.label": "File extensions", "admin.registries.bitstream-formats.edit.extensions.label": "Faila paplašinājums", - + // "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extension without the dot", "admin.registries.bitstream-formats.edit.extensions.placeholder": "Ievadiet faila paplašinājumu bez punkta", - + // "admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.", "admin.registries.bitstream-formats.edit.failure.content": "Rediģējot bitu straumes formātu, radās kļūda.", - + // "admin.registries.bitstream-formats.edit.failure.head": "Failure", "admin.registries.bitstream-formats.edit.failure.head": "Kļūda", - + // "admin.registries.bitstream-formats.edit.head": "Bitstream format: {{ format }}", "admin.registries.bitstream-formats.edit.head": "Bitu straumes formāts: {{ format }}", - + // "admin.registries.bitstream-formats.edit.internal.hint": "Formats marked as internal are hidden from the user, and used for administrative purposes.", "admin.registries.bitstream-formats.edit.internal.hint": "Formāti, kas atzīmēti kā iekšēji, tiek paslēpti no lietotāja un tiek izmantoti administratīviem mērķiem.", - + // "admin.registries.bitstream-formats.edit.internal.label": "Internal", "admin.registries.bitstream-formats.edit.internal.label": "Iekšējais", - + // "admin.registries.bitstream-formats.edit.mimetype.hint": "The MIME type associated with this format, does not have to be unique.", "admin.registries.bitstream-formats.edit.mimetype.hint": "MIME tipam, kas saistīts ar šo formātu, nav jābūt unikālam.", - + // "admin.registries.bitstream-formats.edit.mimetype.label": "MIME Type", "admin.registries.bitstream-formats.edit.mimetype.label": "MIME Tips", - + // "admin.registries.bitstream-formats.edit.shortDescription.hint": "A unique name for this format, (e.g. Microsoft Word XP or Microsoft Word 2000)", "admin.registries.bitstream-formats.edit.shortDescription.hint": "Šim formātam ir unikāls nosaukums, (piem. Microsoft Word XP or Microsoft Word 2000)", - + // "admin.registries.bitstream-formats.edit.shortDescription.label": "Name", "admin.registries.bitstream-formats.edit.shortDescription.label": "Nosaukums", - + // "admin.registries.bitstream-formats.edit.success.content": "The bitstream format was successfully edited.", "admin.registries.bitstream-formats.edit.success.content": "Bitu straumes formāts tika veiksmīgi rediģēts.", - + // "admin.registries.bitstream-formats.edit.success.head": "Success", "admin.registries.bitstream-formats.edit.success.head": "Veiksmīgi izpildīts", - + // "admin.registries.bitstream-formats.edit.supportLevel.hint": "The level of support your institution pledges for this format.", "admin.registries.bitstream-formats.edit.supportLevel.hint": "Atbalsta līmenis, ko jūsu iestāde apņemas nodrošināt šim formātam.", - + // "admin.registries.bitstream-formats.edit.supportLevel.label": "Support level", "admin.registries.bitstream-formats.edit.supportLevel.label": "Atbalsta līmenis", - + // "admin.registries.bitstream-formats.head": "Bitstream Format Registry", "admin.registries.bitstream-formats.head": "Bitu straumes Formatu Reģistrs", - + // "admin.registries.bitstream-formats.no-items": "No bitstream formats to show.", "admin.registries.bitstream-formats.no-items": "Nav rādāmi bitu straumes formāti.", - + // "admin.registries.bitstream-formats.table.delete": "Delete selected", "admin.registries.bitstream-formats.table.delete": "Dzēst atlasīto", - + // "admin.registries.bitstream-formats.table.deselect-all": "Deselect all", "admin.registries.bitstream-formats.table.deselect-all": "Noņemt atlasi", - + // "admin.registries.bitstream-formats.table.internal": "internal", "admin.registries.bitstream-formats.table.internal": "Iekšējais", - + // "admin.registries.bitstream-formats.table.mimetype": "MIME Type", "admin.registries.bitstream-formats.table.mimetype": "MIME Tips", - + // "admin.registries.bitstream-formats.table.name": "Name", "admin.registries.bitstream-formats.table.name": "Nosaukums", - + // "admin.registries.bitstream-formats.table.return": "Return", "admin.registries.bitstream-formats.table.return": "Atgriezties", - + // "admin.registries.bitstream-formats.table.supportLevel.KNOWN": "Known", "admin.registries.bitstream-formats.table.supportLevel.KNOWN": "Zināms", - + // "admin.registries.bitstream-formats.table.supportLevel.SUPPORTED": "Supported", "admin.registries.bitstream-formats.table.supportLevel.SUPPORTED": "Atbalstīts", - + // "admin.registries.bitstream-formats.table.supportLevel.UNKNOWN": "Unknown", "admin.registries.bitstream-formats.table.supportLevel.UNKNOWN": "Nezināms", - + // "admin.registries.bitstream-formats.table.supportLevel.head": "Support Level", "admin.registries.bitstream-formats.table.supportLevel.head": "Atbalsta Līmenis", - + // "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry", "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitu straumes Formatu Reģistrs", - - - + + + // "admin.registries.metadata.breadcrumbs": "Metadata registry", // TODO New key - Add a translation "admin.registries.metadata.breadcrumbs": "Metadata registry", - + // "admin.registries.metadata.description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.", "admin.registries.metadata.description": "Metadatu reģistrā tiek uzturēts visu repozitorijā pieejamo metadatu lauku saraksts. Šos laukus var sadalīt starp vairākām shēmām. Tomēr DSpace ir nepieciešama kvalificēta Dublin Core shēma.", - + // "admin.registries.metadata.form.create": "Create metadata schema", "admin.registries.metadata.form.create": "Izveidot metadatu shēmu", - + // "admin.registries.metadata.form.edit": "Edit metadata schema", "admin.registries.metadata.form.edit": "Rediģēt metadatu shēmu", - + // "admin.registries.metadata.form.name": "Name", "admin.registries.metadata.form.name": "Nosaukums", - + // "admin.registries.metadata.form.namespace": "Namespace", "admin.registries.metadata.form.namespace": "Nosaukumvieta", - + // "admin.registries.metadata.head": "Metadata Registry", "admin.registries.metadata.head": "Metadatu reģistrs", - + // "admin.registries.metadata.schemas.no-items": "No metadata schemas to show.", "admin.registries.metadata.schemas.no-items": "Nav rādāmas metadatu shēmas.", - + // "admin.registries.metadata.schemas.table.delete": "Delete selected", "admin.registries.metadata.schemas.table.delete": "Dzēst izvēlēto", - + // "admin.registries.metadata.schemas.table.id": "ID", "admin.registries.metadata.schemas.table.id": "ID", - + // "admin.registries.metadata.schemas.table.name": "Name", "admin.registries.metadata.schemas.table.name": "Nosaukums", - + // "admin.registries.metadata.schemas.table.namespace": "Namespace", "admin.registries.metadata.schemas.table.namespace": "Nosaukumvieta", - + // "admin.registries.metadata.title": "DSpace Angular :: Metadata Registry", "admin.registries.metadata.title": "DSpace Angular :: Metadatu Registry", - - - + + + // "admin.registries.schema.breadcrumbs": "Metadata schema", // TODO New key - Add a translation "admin.registries.schema.breadcrumbs": "Metadata schema", - + // "admin.registries.schema.description": "This is the metadata schema for \"{{namespace}}\".", "admin.registries.schema.description": "Metadu shēma priekš \"{{namespace}}\".", - + // "admin.registries.schema.fields.head": "Schema metadata fields", "admin.registries.schema.fields.head": "Shēmas metadatu lauki", - + // "admin.registries.schema.fields.no-items": "No metadata fields to show.", "admin.registries.schema.fields.no-items": "Nav pieejami metadatu lauki.", - + // "admin.registries.schema.fields.table.delete": "Delete selected", "admin.registries.schema.fields.table.delete": "Dzēst izvēlēto", - + // "admin.registries.schema.fields.table.field": "Field", "admin.registries.schema.fields.table.field": "Lauks", - + // "admin.registries.schema.fields.table.scopenote": "Scope Note", "admin.registries.schema.fields.table.scopenote": "Jomas Piezīme", - + // "admin.registries.schema.form.create": "Create metadata field", "admin.registries.schema.form.create": "Izveidot matadatu lauku", - + // "admin.registries.schema.form.edit": "Edit metadata field", "admin.registries.schema.form.edit": "Rediģēt metadatu lauku", - + // "admin.registries.schema.form.element": "Element", "admin.registries.schema.form.element": "Elements", - + // "admin.registries.schema.form.qualifier": "Qualifier", "admin.registries.schema.form.qualifier": "Kvalifikācija", - + // "admin.registries.schema.form.scopenote": "Scope Note", "admin.registries.schema.form.scopenote": "Jomas Piezīme", - + // "admin.registries.schema.head": "Metadata Schema", "admin.registries.schema.head": "Metadatu Shēma", - + // "admin.registries.schema.notification.created": "Successfully created metadata schema \"{{prefix}}\"", "admin.registries.schema.notification.created": "Veiksmīgi izveidota metadatu shēma \"{{prefix}}\"", - + // "admin.registries.schema.notification.deleted.failure": "Failed to delete {{amount}} metadata schemas", "admin.registries.schema.notification.deleted.failure": "Neizdevās izdzēst {{amount}} metadatu shēmas", - + // "admin.registries.schema.notification.deleted.success": "Successfully deleted {{amount}} metadata schemas", "admin.registries.schema.notification.deleted.success": "{{amount}} metadatu shēmas ir veiksmīgi izdzēstas", - + // "admin.registries.schema.notification.edited": "Successfully edited metadata schema \"{{prefix}}\"", "admin.registries.schema.notification.edited": "Veiksmīgi rediģēta metadatu shēma \"{{prefix}}\"", - + // "admin.registries.schema.notification.failure": "Error", "admin.registries.schema.notification.failure": "Kļūda", - + // "admin.registries.schema.notification.field.created": "Successfully created metadata field \"{{field}}\"", "admin.registries.schema.notification.field.created": "Metadatu lauks ir veiksmīgi izveidots \"{{field}}\"", - + // "admin.registries.schema.notification.field.deleted.failure": "Failed to delete {{amount}} metadata fields", "admin.registries.schema.notification.field.deleted.failure": "Neizdevās izdzēst {{amount}} metadatu laukus", - + // "admin.registries.schema.notification.field.deleted.success": "Successfully deleted {{amount}} metadata fields", "admin.registries.schema.notification.field.deleted.success": "{{amount}} metadatu lauki ir veiksmīgi izdzēsti", - + // "admin.registries.schema.notification.field.edited": "Successfully edited metadata field \"{{field}}\"", "admin.registries.schema.notification.field.edited": "Metadatu lauks ir veiksmīgi rediģēts \"{{field}}\"", - + // "admin.registries.schema.notification.success": "Success", "admin.registries.schema.notification.success": "Veiksmīgi izpildīts", - + // "admin.registries.schema.return": "Return", "admin.registries.schema.return": "Atgriezties", - + // "admin.registries.schema.title": "DSpace Angular :: Metadata Schema Registry", "admin.registries.schema.title": "DSpace Angular :: Metadatu shēmas reģistrs", - - - + + + // "admin.access-control.epeople.actions.delete": "Delete EPerson", // TODO New key - Add a translation "admin.access-control.epeople.actions.delete": "Delete EPerson", - + // "admin.access-control.epeople.actions.impersonate": "Impersonate EPerson", // TODO New key - Add a translation "admin.access-control.epeople.actions.impersonate": "Impersonate EPerson", - + // "admin.access-control.epeople.actions.reset": "Reset password", // TODO New key - Add a translation "admin.access-control.epeople.actions.reset": "Reset password", - + // "admin.access-control.epeople.actions.stop-impersonating": "Stop impersonating EPerson", // TODO New key - Add a translation "admin.access-control.epeople.actions.stop-impersonating": "Stop impersonating EPerson", - + // "admin.access-control.epeople.title": "DSpace Angular :: EPeople", "admin.access-control.epeople.title": "DSpace Angular :: EPersonas", - + // "admin.access-control.epeople.head": "EPeople", "admin.access-control.epeople.head": "EPersonas", - + // "admin.access-control.epeople.search.head": "Search", "admin.access-control.epeople.search.head": "Meklēt", - + // "admin.access-control.epeople.button.see-all": "Browse All", "admin.access-control.epeople.button.see-all": "Skatīt visus", - + // "admin.access-control.epeople.search.scope.metadata": "Metadata", "admin.access-control.epeople.search.scope.metadata": "Metadati", - + // "admin.access-control.epeople.search.scope.email": "E-mail (exact)", "admin.access-control.epeople.search.scope.email": "E-pasts (pilns)", - + // "admin.access-control.epeople.search.button": "Search", "admin.access-control.epeople.search.button": "Meklēt", - + // "admin.access-control.epeople.button.add": "Add EPerson", "admin.access-control.epeople.button.add": "Pievienot EPersonu", - + // "admin.access-control.epeople.table.id": "ID", "admin.access-control.epeople.table.id": "ID", - + // "admin.access-control.epeople.table.name": "Name", "admin.access-control.epeople.table.name": "Lietotājs", - + // "admin.access-control.epeople.table.email": "E-mail (exact)", "admin.access-control.epeople.table.email": "E-pasts (precīzs)", - + // "admin.access-control.epeople.table.edit": "Edit", "admin.access-control.epeople.table.edit": "Rediģēt", - + // "admin.access-control.epeople.table.edit.buttons.edit": "Edit \"{{name}}\"", "admin.access-control.epeople.table.edit.buttons.edit": "Rediģēt \"{{name}}\"", - + // "admin.access-control.epeople.table.edit.buttons.remove": "Delete \"{{name}}\"", "admin.access-control.epeople.table.edit.buttons.remove": "Dzēst \"{{name}}\"", - + // "admin.access-control.epeople.no-items": "No EPeople to show.", "admin.access-control.epeople.no-items": "Nav pieejamas EPersonas.", - + // "admin.access-control.epeople.form.create": "Create EPerson", "admin.access-control.epeople.form.create": "Izveidot EPersonu", - + // "admin.access-control.epeople.form.edit": "Edit EPerson", "admin.access-control.epeople.form.edit": "Rediģēt EPersonu", - + // "admin.access-control.epeople.form.firstName": "First name", "admin.access-control.epeople.form.firstName": "Vārds", - + // "admin.access-control.epeople.form.lastName": "Last name", "admin.access-control.epeople.form.lastName": "Uzvārds", - + // "admin.access-control.epeople.form.email": "E-mail", "admin.access-control.epeople.form.email": "E-pasts", - + // "admin.access-control.epeople.form.emailHint": "Must be valid e-mail address", "admin.access-control.epeople.form.emailHint": "Jābūt derīgai e-pasta adresei", - + // "admin.access-control.epeople.form.canLogIn": "Can log in", "admin.access-control.epeople.form.canLogIn": "Var pierakstīties", - + // "admin.access-control.epeople.form.requireCertificate": "Requires certificate", "admin.access-control.epeople.form.requireCertificate": "Nepieciešams sertifikāts", - + // "admin.access-control.epeople.form.notification.created.success": "Successfully created EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.created.success": "Veiksmīgi izveidota EPersona \"{{name}}\"", - + // "admin.access-control.epeople.form.notification.created.failure": "Failed to create EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.created.failure": "Neizdevās izveidot EPersonu \"{{name}}\"", - + // "admin.access-control.epeople.form.notification.created.failure.emailInUse": "Failed to create EPerson \"{{name}}\", email \"{{email}}\" already in use.", "admin.access-control.epeople.form.notification.created.failure.emailInUse": "Neizdevās izveidot EPersonu \"{{name}}\", e-pasts \"{{email}}\" jau tiek lietots.", - + // "admin.access-control.epeople.form.notification.edited.failure.emailInUse": "Failed to edit EPerson \"{{name}}\", email \"{{email}}\" already in use.", "admin.access-control.epeople.form.notification.edited.failure.emailInUse": "Neizdevās rediģēt EPersonu \"{{name}}\", e-pasts \"{{email}}\" jau tiek lietots.", - + // "admin.access-control.epeople.form.notification.edited.success": "Successfully edited EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.edited.success": "Veiksmīgi rediģēta EPersona \"{{name}}\"", - + // "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", "admin.access-control.epeople.form.notification.edited.failure": "Neizdevās rediģēt EPerosnu \"{{name}}\"", - + // "admin.access-control.epeople.form.notification.deleted.success": "Successfully deleted EPerson \"{{name}}\"", // TODO New key - Add a translation "admin.access-control.epeople.form.notification.deleted.success": "Successfully deleted EPerson \"{{name}}\"", - + // "admin.access-control.epeople.form.notification.deleted.failure": "Failed to delete EPerson \"{{name}}\"", // TODO New key - Add a translation "admin.access-control.epeople.form.notification.deleted.failure": "Failed to delete EPerson \"{{name}}\"", - + // "admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Member of these groups:", "admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Grupas lietotāji:", - + // "admin.access-control.epeople.form.table.id": "ID", "admin.access-control.epeople.form.table.id": "ID", - + // "admin.access-control.epeople.form.table.name": "Name", "admin.access-control.epeople.form.table.name": "Vārds", - + // "admin.access-control.epeople.form.memberOfNoGroups": "This EPerson is not a member of any groups", "admin.access-control.epeople.form.memberOfNoGroups": "EPersona nepieder nevienai grupai", - + // "admin.access-control.epeople.form.goToGroups": "Add to groups", "admin.access-control.epeople.form.goToGroups": "Pievienot grupām", - + // "admin.access-control.epeople.notification.deleted.failure": "Failed to delete EPerson: \"{{name}}\"", "admin.access-control.epeople.notification.deleted.failure": "Neizdevās dzēst EPersonu: \"{{name}}\"", - + // "admin.access-control.epeople.notification.deleted.success": "Successfully deleted EPerson: \"{{name}}\"", "admin.access-control.epeople.notification.deleted.success": "Veiksmīgi dzēsta EPersona: \"{{name}}\"", - - - + + + // "admin.access-control.groups.title": "DSpace Angular :: Groups", "admin.access-control.groups.title": "DSpace Angular :: Grupas", - + // "admin.access-control.groups.title.singleGroup": "DSpace Angular :: Edit Group", // TODO New key - Add a translation "admin.access-control.groups.title.singleGroup": "DSpace Angular :: Edit Group", - + // "admin.access-control.groups.title.addGroup": "DSpace Angular :: New Group", // TODO New key - Add a translation "admin.access-control.groups.title.addGroup": "DSpace Angular :: New Group", - + // "admin.access-control.groups.head": "Groups", "admin.access-control.groups.head": "Grupas", - + // "admin.access-control.groups.button.add": "Add group", "admin.access-control.groups.button.add": "Pievienot grupu", - + // "admin.access-control.groups.search.head": "Search groups", "admin.access-control.groups.search.head": "Meklēt grupas", - + // "admin.access-control.groups.button.see-all": "Browse all", "admin.access-control.groups.button.see-all": "Skatīt visas", - + // "admin.access-control.groups.search.button": "Search", "admin.access-control.groups.search.button": "Meklēt", - + // "admin.access-control.groups.table.id": "ID", "admin.access-control.groups.table.id": "ID", - + // "admin.access-control.groups.table.name": "Name", "admin.access-control.groups.table.name": "Vārds", - + // "admin.access-control.groups.table.members": "Members", "admin.access-control.groups.table.members": "Lietotāji", - + // "admin.access-control.groups.table.edit": "Edit", "admin.access-control.groups.table.edit": "Rediģēt", - + // "admin.access-control.groups.table.edit.buttons.edit": "Edit \"{{name}}\"", "admin.access-control.groups.table.edit.buttons.edit": "Rediģēt \"{{name}}\"", - + // "admin.access-control.groups.table.edit.buttons.remove": "Delete \"{{name}}\"", "admin.access-control.groups.table.edit.buttons.remove": "Dzēst \"{{name}}\"", - + // "admin.access-control.groups.no-items": "No groups found with this in their name or this as UUID", "admin.access-control.groups.no-items": "Netika atrasta grupa ar šādu nosaukumu vai identifikatoru", - + // "admin.access-control.groups.notification.deleted.success": "Successfully deleted group \"{{name}}\"", "admin.access-control.groups.notification.deleted.success": "Veiksmīgi dzēsta grupa \"{{name}}\"", - + // "admin.access-control.groups.notification.deleted.failure.title": "Failed to delete group \"{{name}}\"", // TODO New key - Add a translation "admin.access-control.groups.notification.deleted.failure.title": "Failed to delete group \"{{name}}\"", - + // "admin.access-control.groups.notification.deleted.failure.content": "Cause: \"{{cause}}\"", // TODO New key - Add a translation "admin.access-control.groups.notification.deleted.failure.content": "Cause: \"{{cause}}\"", - - - + + + // "admin.access-control.groups.form.alert.permanent": "This group is permanent, so it can't be edited or deleted. You can still add and remove group members using this page.", // TODO New key - Add a translation "admin.access-control.groups.form.alert.permanent": "This group is permanent, so it can't be edited or deleted. You can still add and remove group members using this page.", - + // "admin.access-control.groups.form.alert.workflowGroup": "This group can’t be modified or deleted because it corresponds to a role in the submission and workflow process in the \"{{name}}\" {{comcol}}. You can delete it from the \"assign roles\" tab on the edit {{comcol}} page. You can still add and remove group members using this page.", // TODO New key - Add a translation "admin.access-control.groups.form.alert.workflowGroup": "This group can’t be modified or deleted because it corresponds to a role in the submission and workflow process in the \"{{name}}\" {{comcol}}. You can delete it from the \"assign roles\" tab on the edit {{comcol}} page. You can still add and remove group members using this page.", - + // "admin.access-control.groups.form.head.create": "Create group", "admin.access-control.groups.form.head.create": "Izveidot grupu", - + // "admin.access-control.groups.form.head.edit": "Edit group", "admin.access-control.groups.form.head.edit": "Rediģēt grupu", - + // "admin.access-control.groups.form.groupName": "Group name", "admin.access-control.groups.form.groupName": "Grupas nosaukums", - + // "admin.access-control.groups.form.groupDescription": "Description", "admin.access-control.groups.form.groupDescription": "Apraksts", - + // "admin.access-control.groups.form.notification.created.success": "Successfully created Group \"{{name}}\"", "admin.access-control.groups.form.notification.created.success": "Izveidota grupa \"{{name}}\"", - + // "admin.access-control.groups.form.notification.created.failure": "Failed to create Group \"{{name}}\"", "admin.access-control.groups.form.notification.created.failure": "Neizdevās izveidot grupu \"{{name}}\"", - + // "admin.access-control.groups.form.notification.created.failure.groupNameInUse": "Failed to create Group with name: \"{{name}}\", make sure the name is not already in use.", "admin.access-control.groups.form.notification.created.failure.groupNameInUse": "Neizdevās izveidot grupu ar nosaukumu: \"{{name}}\", pārliecinies vai grupa ar šādu nosaukumu jau netiek izmantota.", - + // "admin.access-control.groups.form.notification.edited.failure": "Failed to edit Group \"{{name}}\"", // TODO New key - Add a translation "admin.access-control.groups.form.notification.edited.failure": "Failed to edit Group \"{{name}}\"", - + // "admin.access-control.groups.form.notification.edited.failure.groupNameInUse": "Name \"{{name}}\" already in use!", // TODO New key - Add a translation "admin.access-control.groups.form.notification.edited.failure.groupNameInUse": "Name \"{{name}}\" already in use!", - + // "admin.access-control.groups.form.notification.edited.success": "Successfully edited Group \"{{name}}\"", // TODO New key - Add a translation "admin.access-control.groups.form.notification.edited.success": "Successfully edited Group \"{{name}}\"", - + // "admin.access-control.groups.form.actions.delete": "Delete Group", // TODO New key - Add a translation "admin.access-control.groups.form.actions.delete": "Delete Group", - + // "admin.access-control.groups.form.delete-group.modal.header": "Delete Group \"{{ dsoName }}\"", // TODO New key - Add a translation "admin.access-control.groups.form.delete-group.modal.header": "Delete Group \"{{ dsoName }}\"", - + // "admin.access-control.groups.form.delete-group.modal.info": "Are you sure you want to delete Group \"{{ dsoName }}\"", // TODO New key - Add a translation "admin.access-control.groups.form.delete-group.modal.info": "Are you sure you want to delete Group \"{{ dsoName }}\"", - + // "admin.access-control.groups.form.delete-group.modal.cancel": "Cancel", // TODO New key - Add a translation "admin.access-control.groups.form.delete-group.modal.cancel": "Cancel", - + // "admin.access-control.groups.form.delete-group.modal.confirm": "Delete", // TODO New key - Add a translation "admin.access-control.groups.form.delete-group.modal.confirm": "Delete", - + // "admin.access-control.groups.form.notification.deleted.success": "Successfully deleted group \"{{ name }}\"", // TODO New key - Add a translation "admin.access-control.groups.form.notification.deleted.success": "Successfully deleted group \"{{ name }}\"", - + // "admin.access-control.groups.form.notification.deleted.failure.title": "Failed to delete group \"{{ name }}\"", // TODO New key - Add a translation "admin.access-control.groups.form.notification.deleted.failure.title": "Failed to delete group \"{{ name }}\"", - + // "admin.access-control.groups.form.notification.deleted.failure.content": "Cause: \"{{ cause }}\"", // TODO New key - Add a translation "admin.access-control.groups.form.notification.deleted.failure.content": "Cause: \"{{ cause }}\"", - + // "admin.access-control.groups.form.members-list.head": "EPeople", "admin.access-control.groups.form.members-list.head": "EPersona", - + // "admin.access-control.groups.form.members-list.search.head": "Add EPeople", "admin.access-control.groups.form.members-list.search.head": "Pievienot EPersonu", - + // "admin.access-control.groups.form.members-list.button.see-all": "Browse All", "admin.access-control.groups.form.members-list.button.see-all": "Skatīt visus", - + // "admin.access-control.groups.form.members-list.headMembers": "Current Members", "admin.access-control.groups.form.members-list.headMembers": "Pašreizējie lietotāji", - + // "admin.access-control.groups.form.members-list.search.scope.metadata": "Metadata", "admin.access-control.groups.form.members-list.search.scope.metadata": "Metadats", - + // "admin.access-control.groups.form.members-list.search.scope.email": "E-mail (exact)", "admin.access-control.groups.form.members-list.search.scope.email": "E-pasts (precīzi)", - + // "admin.access-control.groups.form.members-list.search.button": "Search", "admin.access-control.groups.form.members-list.search.button": "Meklēt", - + // "admin.access-control.groups.form.members-list.table.id": "ID", "admin.access-control.groups.form.members-list.table.id": "ID", - + // "admin.access-control.groups.form.members-list.table.name": "Name", "admin.access-control.groups.form.members-list.table.name": "Nosaukums", - + // "admin.access-control.groups.form.members-list.table.edit": "Remove / Add", "admin.access-control.groups.form.members-list.table.edit": "Noņemt / Pievienot", - + // "admin.access-control.groups.form.members-list.table.edit.buttons.remove": "Remove member with name \"{{name}}\"", "admin.access-control.groups.form.members-list.table.edit.buttons.remove": "Noņemt lietotāju ar vārdu \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.notification.success.addMember": "Successfully added member: \"{{name}}\"", "admin.access-control.groups.form.members-list.notification.success.addMember": "Veiksmīgi pievienots lietotājs: \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.notification.failure.addMember": "Failed to add member: \"{{name}}\"", "admin.access-control.groups.form.members-list.notification.failure.addMember": "Neizdevās pievienot lietotāju: \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.notification.success.deleteMember": "Successfully deleted member: \"{{name}}\"", "admin.access-control.groups.form.members-list.notification.success.deleteMember": "Veiksmīgi dzēsts lietotājs: \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.notification.failure.deleteMember": "Failed to delete member: \"{{name}}\"", "admin.access-control.groups.form.members-list.notification.failure.deleteMember": "Neizdevās dzēst lietotāju: \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.table.edit.buttons.add": "Add member with name \"{{name}}\"", "admin.access-control.groups.form.members-list.table.edit.buttons.add": "Pievienot lietotāju ar vārdu \"{{name}}\"", - + // "admin.access-control.groups.form.members-list.notification.failure.noActiveGroup": "No current active group, submit a name first.", "admin.access-control.groups.form.members-list.notification.failure.noActiveGroup": "Pašlaik nav aktīvu grupu, iesniegt sākumā vārdu.", - + // "admin.access-control.groups.form.members-list.no-members-yet": "No members in group yet, search and add.", "admin.access-control.groups.form.members-list.no-members-yet": "Grupā nav lietotāju, atrast un pievienot.", - + // "admin.access-control.groups.form.members-list.no-items": "No EPeople found in that search", "admin.access-control.groups.form.members-list.no-items": "Netika atrasta neviena EPersona", - + // "admin.access-control.groups.form.subgroups-list.notification.failure": "Something went wrong: \"{{cause}}\"", // TODO New key - Add a translation "admin.access-control.groups.form.subgroups-list.notification.failure": "Something went wrong: \"{{cause}}\"", - + // "admin.access-control.groups.form.subgroups-list.head": "Groups", "admin.access-control.groups.form.subgroups-list.head": "Grupas", - + // "admin.access-control.groups.form.subgroups-list.search.head": "Add Subgroup", "admin.access-control.groups.form.subgroups-list.search.head": "Pievienot apakšgrupu", - + // "admin.access-control.groups.form.subgroups-list.button.see-all": "Browse All", "admin.access-control.groups.form.subgroups-list.button.see-all": "Skatīt visus", - + // "admin.access-control.groups.form.subgroups-list.headSubgroups": "Current Subgroups", "admin.access-control.groups.form.subgroups-list.headSubgroups": "Pašreizējās apakšgrupas", - + // "admin.access-control.groups.form.subgroups-list.search.button": "Search", "admin.access-control.groups.form.subgroups-list.search.button": "Meklēt", - + // "admin.access-control.groups.form.subgroups-list.table.id": "ID", "admin.access-control.groups.form.subgroups-list.table.id": "ID", - + // "admin.access-control.groups.form.subgroups-list.table.name": "Name", "admin.access-control.groups.form.subgroups-list.table.name": "Vārds", - + // "admin.access-control.groups.form.subgroups-list.table.edit": "Remove / Add", "admin.access-control.groups.form.subgroups-list.table.edit": "Noņemt / Pievienot", - + // "admin.access-control.groups.form.subgroups-list.table.edit.buttons.remove": "Remove subgroup with name \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.table.edit.buttons.remove": "Noņemt apakšgrupu ar nosaukumu \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.table.edit.buttons.add": "Add subgroup with name \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.table.edit.buttons.add": "Pievienot apakšgrupu ar nosaukumu \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.table.edit.currentGroup": "Current group", "admin.access-control.groups.form.subgroups-list.table.edit.currentGroup": "Pašreizējā grupa", - + // "admin.access-control.groups.form.subgroups-list.notification.success.addSubgroup": "Successfully added subgroup: \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.notification.success.addSubgroup": "Veiksmīgi pievienota apakšgrupa: \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.notification.failure.addSubgroup": "Failed to add subgroup: \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.notification.failure.addSubgroup": "Neizdevās pievienot apakšgrupu: \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.notification.success.deleteSubgroup": "Successfully deleted subgroup: \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.notification.success.deleteSubgroup": "Veiksmīgi dzēsta apakšgrupa: \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.notification.failure.deleteSubgroup": "Failed to delete subgroup: \"{{name}}\"", "admin.access-control.groups.form.subgroups-list.notification.failure.deleteSubgroup": "Neizdevās dzēst apakšgrupu: \"{{name}}\"", - + // "admin.access-control.groups.form.subgroups-list.notification.failure.noActiveGroup": "No current active group, submit a name first.", "admin.access-control.groups.form.subgroups-list.notification.failure.noActiveGroup": "Pašlaik nav aktīvu grupu, iesniegt sākumā vārdu.", - + // "admin.access-control.groups.form.subgroups-list.notification.failure.subgroupToAddIsActiveGroup": "This is the current group, can't be added.", "admin.access-control.groups.form.subgroups-list.notification.failure.subgroupToAddIsActiveGroup": "Šī ir pašreizēja grupa, nevar pievienot", - + // "admin.access-control.groups.form.subgroups-list.no-items": "No groups found with this in their name or this as UUID", "admin.access-control.groups.form.subgroups-list.no-items": "Netika atrasta grupa ar šādu nosaukumu vai identifikatoru", - + // "admin.access-control.groups.form.subgroups-list.no-subgroups-yet": "No subgroups in group yet.", "admin.access-control.groups.form.subgroups-list.no-subgroups-yet": "Grupā pašreiz nav apakšgrupu.", - + // "admin.access-control.groups.form.return": "Return to groups", "admin.access-control.groups.form.return": "Atgriezties pie grupām", - - - + + + // "admin.search.breadcrumbs": "Administrative Search", "admin.search.breadcrumbs": "Administratīvā Meklēšana", - + // "admin.search.collection.edit": "Edit", "admin.search.collection.edit": "Rediģēt", - + // "admin.search.community.edit": "Edit", "admin.search.community.edit": "Rediģēt", - + // "admin.search.item.delete": "Delete", "admin.search.item.delete": "Dzēst", - + // "admin.search.item.edit": "Edit", "admin.search.item.edit": "Rediģēt", - + // "admin.search.item.make-private": "Make Private", "admin.search.item.make-private": "Padarīt Privātu", - + // "admin.search.item.make-public": "Make Public", "admin.search.item.make-public": "Padarīt Publisku", - + // "admin.search.item.move": "Move", "admin.search.item.move": "Pārvietot", - + // "admin.search.item.reinstate": "Reinstate", "admin.search.item.reinstate": "Atjaunot", - + // "admin.search.item.withdraw": "Withdraw", "admin.search.item.withdraw": "Atsaukt", - + // "admin.search.title": "Administrative Search", "admin.search.title": "Administratīvā Meklēšana", - + // "administrativeView.search.results.head": "Administrative Search", "administrativeView.search.results.head": "Administratīvā meklēšana", - - - - + + + + // "admin.workflow.breadcrumbs": "Administer Workflow", "admin.workflow.breadcrumbs": "Administrēt darba plūsmu", - + // "admin.workflow.title": "Administer Workflow", "admin.workflow.title": "Administrēt darba plūsmu", - + // "admin.workflow.item.workflow": "Workflow", "admin.workflow.item.workflow": "Darba plūsma", - + // "admin.workflow.item.delete": "Delete", "admin.workflow.item.delete": "Dzēst", - + // "admin.workflow.item.send-back": "Send back", "admin.workflow.item.send-back": "Sūtīt atpakaļ", - - - + + + // "admin.metadata-import.breadcrumbs": "Import Metadata", // TODO New key - Add a translation "admin.metadata-import.breadcrumbs": "Import Metadata", - + // "admin.metadata-import.title": "Import Metadata", // TODO New key - Add a translation "admin.metadata-import.title": "Import Metadata", - + // "admin.metadata-import.page.header": "Import Metadata", // TODO New key - Add a translation "admin.metadata-import.page.header": "Import Metadata", - + // "admin.metadata-import.page.help": "You can drop or browse CSV files that contain batch metadata operations on files here", // TODO New key - Add a translation "admin.metadata-import.page.help": "You can drop or browse CSV files that contain batch metadata operations on files here", - + // "admin.metadata-import.page.dropMsg": "Drop a metadata CSV to import", // TODO New key - Add a translation "admin.metadata-import.page.dropMsg": "Drop a metadata CSV to import", - + // "admin.metadata-import.page.dropMsgReplace": "Drop to replace the metadata CSV to import", // TODO New key - Add a translation "admin.metadata-import.page.dropMsgReplace": "Drop to replace the metadata CSV to import", - + // "admin.metadata-import.page.button.return": "Return", // TODO New key - Add a translation "admin.metadata-import.page.button.return": "Return", - + // "admin.metadata-import.page.button.proceed": "Proceed", // TODO New key - Add a translation "admin.metadata-import.page.button.proceed": "Proceed", - + // "admin.metadata-import.page.error.addFile": "Select file first!", // TODO New key - Add a translation "admin.metadata-import.page.error.addFile": "Select file first!", - - - - + + + + // "auth.errors.invalid-user": "Invalid email address or password.", "auth.errors.invalid-user": "Nepareizs e-pasta adrese vai parole.", - + // "auth.messages.expired": "Your session has expired. Please log in again.", "auth.messages.expired": "Jūsu sesija ir beigusies. Lūdzu pieslēdzieties vēlreiz", - - - + + + // "bitstream.edit.bitstream": "Bitstream: ", "bitstream.edit.bitstream": "Bitu straume: ", - + // "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", "bitstream.edit.form.description.hint": "Pēc izvēles sniedziet īsu faila aprakstu, piemēram, \"Galvanais raksts\" vai \"Eksperimenta datu nolasījumi\".", - + // "bitstream.edit.form.description.label": "Description", "bitstream.edit.form.description.label": "Apraksts", - + // "bitstream.edit.form.embargo.hint": "The first day from which access is allowed. This date cannot be modified on this form. To set an embargo date for a bitstream, go to the Item Status tab, click Authorizations..., create or edit the bitstream's READ policy, and set the Start Date as desired.", "bitstream.edit.form.embargo.hint": "Pirmā diena, no kuras atļauta pieeja. Šai formai nevarēs mainīt datumu. Lai uzstādītu embargo datumu šai bitu straumei, doties uz Materiāla statuss, noklikšķiniet uz Autorizācijas..., izveidot vai rediģēt bitu straumes LASĪT politiku, un uzstādīt Sākuma datumu kā vēlaties.", - + // "bitstream.edit.form.embargo.label": "Embargo until specific date", "bitstream.edit.form.embargo.label": "Embargo līdz noteiktam datumam", - + // "bitstream.edit.form.fileName.hint": "Change the filename for the bitstream. Note that this will change the display bitstream URL, but old links will still resolve as long as the sequence ID does not change.", "bitstream.edit.form.fileName.hint": "Mainīt faila nosaukumu bitu straumei. Ņemiet vērā, ka tas mainīs redzamo bitu straumes URL, bet vecās saites joprojām būš pieejamas, kamēr ID secība nemainīsies.", - + // "bitstream.edit.form.fileName.label": "Filename", "bitstream.edit.form.fileName.label": "Faila nosaukums", - + // "bitstream.edit.form.newFormat.label": "Describe new format", "bitstream.edit.form.newFormat.label": "Aprakstīt jauno formātu", - + // "bitstream.edit.form.newFormat.hint": "The application you used to create the file, and the version number (for example, \"ACMESoft SuperApp version 1.5\").", "bitstream.edit.form.newFormat.hint": "Lietojumprogramma, kuru izmantojāt faila izveidošanai, un versijas numurs (piemēram, \" ACMESoft SuperApp versija 1.5 \").", - + // "bitstream.edit.form.primaryBitstream.label": "Primary bitstream", "bitstream.edit.form.primaryBitstream.label": "Galvenā bitu straume", - + // "bitstream.edit.form.selectedFormat.hint": "If the format is not in the above list, select \"format not in list\" above and describe it under \"Describe new format\".", "bitstream.edit.form.selectedFormat.hint": "Ja formāts nav iepriekš minētajā sarakstā, atlasiet “formāts, kurš nav sarakstā” iepriekš un aprakstiet to sadaļā “Aprakstīt jauno formātu”.", - + // "bitstream.edit.form.selectedFormat.label": "Selected Format", "bitstream.edit.form.selectedFormat.label": "Izvēlētais formāts", - + // "bitstream.edit.form.selectedFormat.unknown": "Format not in list", "bitstream.edit.form.selectedFormat.unknown": "Formāts, kurš nav sarakstā", - + // "bitstream.edit.notifications.error.format.title": "An error occurred saving the bitstream's format", "bitstream.edit.notifications.error.format.title": "Saglabājot bitu straumes formātu, radās kļūda", - + // "bitstream.edit.notifications.saved.content": "Your changes to this bitstream were saved.", "bitstream.edit.notifications.saved.content": "Jūsu izmaiņas šajā bitu straumē tika saglabātas.", - + // "bitstream.edit.notifications.saved.title": "Bitstream saved", "bitstream.edit.notifications.saved.title": "Bitu straume saglabāta", - + // "bitstream.edit.title": "Edit bitstream", "bitstream.edit.title": "Rediģēt bitu straumi", - - - + + + // "browse.comcol.by.author": "By Author", "browse.comcol.by.author": "Pēc Autora", - + // "browse.comcol.by.dateissued": "By Issue Date", "browse.comcol.by.dateissued": "Pēc Izdošanas Datuma", - + // "browse.comcol.by.subject": "By Subject", "browse.comcol.by.subject": "Pēc Priekšmeta", - + // "browse.comcol.by.title": "By Title", "browse.comcol.by.title": "Pēc Nosaukuma", - + // "browse.comcol.head": "Browse", "browse.comcol.head": "Pārlūkot", - + // "browse.empty": "No items to show.", "browse.empty": "Ieraksti nav atrasti.", - + // "browse.metadata.author": "Author", "browse.metadata.author": "Autors", - + // "browse.metadata.dateissued": "Issue Date", "browse.metadata.dateissued": "Izdošanas datums", - + // "browse.metadata.subject": "Subject", "browse.metadata.subject": "Priekšmets", - + // "browse.metadata.title": "Title", "browse.metadata.title": "Nosaukums", - + // "browse.metadata.author.breadcrumbs": "Browse by Author", "browse.metadata.author.breadcrumbs": "Meklēt pēc Autora", - + // "browse.metadata.dateissued.breadcrumbs": "Browse by Date", "browse.metadata.dateissued.breadcrumbs": "Meklēt pēc Datuma", - + // "browse.metadata.subject.breadcrumbs": "Browse by Subject", "browse.metadata.subject.breadcrumbs": "Meklēt pēc Priekšmeta", - + // "browse.metadata.title.breadcrumbs": "Browse by Title", "browse.metadata.title.breadcrumbs": "Meklēt pēc Nosaukuma", - + // "browse.startsWith.choose_start": "(Choose start)", "browse.startsWith.choose_start": "(Izvēlieties sākumu)", - + // "browse.startsWith.choose_year": "(Choose year)", "browse.startsWith.choose_year": "(Izvēlieties gadu)", - + // "browse.startsWith.jump": "Jump to a point in the index:", "browse.startsWith.jump": "Pāriet uz punktu indeksā:", - + // "browse.startsWith.months.april": "April", "browse.startsWith.months.april": "Aprīlis", - + // "browse.startsWith.months.august": "August", "browse.startsWith.months.august": "Augusts", - + // "browse.startsWith.months.december": "December", "browse.startsWith.months.december": "Decembris", - + // "browse.startsWith.months.february": "February", "browse.startsWith.months.february": "Februāris", - + // "browse.startsWith.months.january": "January", "browse.startsWith.months.january": "Janvāris", - + // "browse.startsWith.months.july": "July", "browse.startsWith.months.july": "Jūlijs", - + // "browse.startsWith.months.june": "June", "browse.startsWith.months.june": "Jūnijs", - + // "browse.startsWith.months.march": "March", "browse.startsWith.months.march": "Marts", - + // "browse.startsWith.months.may": "May", "browse.startsWith.months.may": "Maijs", - + // "browse.startsWith.months.none": "(Choose month)", "browse.startsWith.months.none": "(Izvēlieties mēnesi)", - + // "browse.startsWith.months.november": "November", "browse.startsWith.months.november": "Novembris", - + // "browse.startsWith.months.october": "October", "browse.startsWith.months.october": "Oktobris", - + // "browse.startsWith.months.september": "September", "browse.startsWith.months.september": "Septembris", - + // "browse.startsWith.submit": "Go", "browse.startsWith.submit": "Izpildīt", - + // "browse.startsWith.type_date": "Or type in a date (year-month):", "browse.startsWith.type_date": "Vai ievadiet datumu (gads-mēnesis):", - + // "browse.startsWith.type_text": "Or enter first few letters:", "browse.startsWith.type_text": "Vai arī ievadiet pirmos burtus:", - + // "browse.title": "Browsing {{ collection }} by {{ field }} {{ value }}", "browse.title": "Meklē {{ collection }} pēc {{ field }} {{ value }}", - - + + // "chips.remove": "Remove chip", "chips.remove": "Dzēst daļu", - - - + + + // "collection.create.head": "Create a Collection", "collection.create.head": "Izveidot Kolekciju", - + // "collection.create.notifications.success": "Successfully created the Collection", "collection.create.notifications.success": "Kolekcija tika veiksmīgi izveidota", - + // "collection.create.sub-head": "Create a Collection for Community {{ parent }}", "collection.create.sub-head": "Izveidot Kolekciju priekš Kategorijas {{ parent }}", - + // "collection.curate.header": "Curate Collection: {{collection}}", // TODO New key - Add a translation "collection.curate.header": "Curate Collection: {{collection}}", - + // "collection.delete.cancel": "Cancel", "collection.delete.cancel": "Atcelt", - + // "collection.delete.confirm": "Confirm", "collection.delete.confirm": "Apstiprināt", - + // "collection.delete.head": "Delete Collection", "collection.delete.head": "Dzēst Kolekciju", - + // "collection.delete.notification.fail": "Collection could not be deleted", "collection.delete.notification.fail": "Kolekciju nevar izdzēst", - + // "collection.delete.notification.success": "Successfully deleted collection", "collection.delete.notification.success": "Kolekcija ir veiksmīgi izdzēsta", - + // "collection.delete.text": "Are you sure you want to delete collection \"{{ dso }}\"", "collection.delete.text": "Vai esat pārliecināts, ka vēlaties izdzēst kolekciju \"{{ dso }}\"", - - - + + + // "collection.edit.delete": "Delete this collection", "collection.edit.delete": "Dzēst šo kolekciju", - + // "collection.edit.head": "Edit Collection", "collection.edit.head": "Rediģēt Kolekciju", - + // "collection.edit.breadcrumbs": "Edit Collection", "collection.edit.breadcrumbs": "Rediģēt Kolekciju", - - - + + + // "collection.edit.tabs.mapper.head": "Item Mapper", // TODO New key - Add a translation "collection.edit.tabs.mapper.head": "Item Mapper", - + // "collection.edit.tabs.item-mapper.title": "Collection Edit - Item Mapper", // TODO New key - Add a translation "collection.edit.tabs.item-mapper.title": "Collection Edit - Item Mapper", - + // "collection.edit.item-mapper.cancel": "Cancel", "collection.edit.item-mapper.cancel": "Atcelt", - + // "collection.edit.item-mapper.collection": "Collection: \"{{name}}\"", "collection.edit.item-mapper.collection": "Kolekcija: \"{{name}}\"", - + // "collection.edit.item-mapper.confirm": "Map selected items", "collection.edit.item-mapper.confirm": "Piesaistīt izvēlētos materiālus", - + // "collection.edit.item-mapper.description": "This is the item mapper tool that allows collection administrators to map items from other collections into this collection. You can search for items from other collections and map them, or browse the list of currently mapped items.", "collection.edit.item-mapper.description": "Šis ir materiālu piesaistīšanas rīks, kas kolekciju administratoriem ļauj šajā kolekcijā piesaistīt citu kolekciju materiālus. Jūs varat meklēt materiālus no citām kolekcijām un piesaistīt tos vai pārlūkot pašlaik piesaistīto materiālu sarakstu.", - + // "collection.edit.item-mapper.head": "Item Mapper - Map Items from Other Collections", "collection.edit.item-mapper.head": "Materiālu piesaistīšana - Materiālu piesaistīšana no citas kolekcijas", - + // "collection.edit.item-mapper.no-search": "Please enter a query to search", "collection.edit.item-mapper.no-search": "Lūdzu ievadiet vaicājumu,lai meklētu", - + // "collection.edit.item-mapper.notifications.map.error.content": "Errors occurred for mapping of {{amount}} items.", "collection.edit.item-mapper.notifications.map.error.content": "{{amount}} materiālu piesaistīšanā radās kļūdas.", - + // "collection.edit.item-mapper.notifications.map.error.head": "Mapping errors", "collection.edit.item-mapper.notifications.map.error.head": "Piesaistīšanas kļūdas", - + // "collection.edit.item-mapper.notifications.map.success.content": "Successfully mapped {{amount}} items.", "collection.edit.item-mapper.notifications.map.success.content": "Veiksmīgi piesaistīti {{amount}} materiāli.", - + // "collection.edit.item-mapper.notifications.map.success.head": "Mapping completed", "collection.edit.item-mapper.notifications.map.success.head": "Piesaistīšana pabeigta", - + // "collection.edit.item-mapper.notifications.unmap.error.content": "Errors occurred for removing the mappings of {{amount}} items.", "collection.edit.item-mapper.notifications.unmap.error.content": "Noņemot piesaistītos materiālus {{amount}}, radās kļūda.", - + // "collection.edit.item-mapper.notifications.unmap.error.head": "Remove mapping errors", "collection.edit.item-mapper.notifications.unmap.error.head": "Dzēst piesaistes kļūdas", - + // "collection.edit.item-mapper.notifications.unmap.success.content": "Successfully removed the mappings of {{amount}} items.", "collection.edit.item-mapper.notifications.unmap.success.content": "Veiksmīgi dzēsta piesaiste {{amount}} materiāliem.", - + // "collection.edit.item-mapper.notifications.unmap.success.head": "Remove mapping completed", "collection.edit.item-mapper.notifications.unmap.success.head": "Piesaistes dzēšana izpildīta", - + // "collection.edit.item-mapper.remove": "Remove selected item mappings", "collection.edit.item-mapper.remove": "Dzēst izvēlēto materiālu piesaistes", - + // "collection.edit.item-mapper.tabs.browse": "Browse mapped items", "collection.edit.item-mapper.tabs.browse": "Pārlūkot piesaistītos materiālus", - + // "collection.edit.item-mapper.tabs.map": "Map new items", "collection.edit.item-mapper.tabs.map": "Piesaistīt jaunus materiālus", - - - + + + // "collection.edit.logo.label": "Collection logo", "collection.edit.logo.label": "Kolekcijas logotips", - + // "collection.edit.logo.notifications.add.error": "Uploading Collection logo failed. Please verify the content before retrying.", "collection.edit.logo.notifications.add.error": "Kolekcijas logotipa augšupielāde neizdevās. Pirms mēģināt vēlreiz, lūdzu, pārbaudiet saturu.", - + // "collection.edit.logo.notifications.add.success": "Upload Collection logo successful.", "collection.edit.logo.notifications.add.success": "Kolekcijas logotipa augšupielāde ir veiksmīga.", - + // "collection.edit.logo.notifications.delete.success.title": "Logo deleted", "collection.edit.logo.notifications.delete.success.title": "Logotips dzēsts", - + // "collection.edit.logo.notifications.delete.success.content": "Successfully deleted the collection's logo", "collection.edit.logo.notifications.delete.success.content": "Kolekcijas logotips ir veiksmīgi izdzēsts", - + // "collection.edit.logo.notifications.delete.error.title": "Error deleting logo", "collection.edit.logo.notifications.delete.error.title": "Kļūda dzēšot logotopu", - + // "collection.edit.logo.upload": "Drop a Collection Logo to upload", "collection.edit.logo.upload": "Ievietot Kolekcijas logotipu, lai augšupielādētu", - - - + + + // "collection.edit.notifications.success": "Successfully edited the Collection", "collection.edit.notifications.success": "Kolekcija ir veiksmīgi rediģēta", - + // "collection.edit.return": "Return", "collection.edit.return": "Atgriezties", - - - + + + // "collection.edit.tabs.curate.head": "Curate", "collection.edit.tabs.curate.head": "Pārvaldīt", - + // "collection.edit.tabs.curate.title": "Collection Edit - Curate", "collection.edit.tabs.curate.title": "Rediģēt kolekciju - Pārvaldība", - + // "collection.edit.tabs.authorizations.head": "Authorizations", // TODO New key - Add a translation "collection.edit.tabs.authorizations.head": "Authorizations", - + // "collection.edit.tabs.authorizations.title": "Collection Edit - Authorizations", // TODO New key - Add a translation "collection.edit.tabs.authorizations.title": "Collection Edit - Authorizations", - + // "collection.edit.tabs.metadata.head": "Edit Metadata", "collection.edit.tabs.metadata.head": "Rediģēt Metadatus", - + // "collection.edit.tabs.metadata.title": "Collection Edit - Metadata", "collection.edit.tabs.metadata.title": "Rediģēt Kolekciju - Metadati", - + // "collection.edit.tabs.roles.head": "Assign Roles", "collection.edit.tabs.roles.head": "Piešķirt Lomu", - + // "collection.edit.tabs.roles.title": "Collection Edit - Roles", "collection.edit.tabs.roles.title": "Rediģēt Kolekciju - Lomas", - + // "collection.edit.tabs.source.external": "This collection harvests its content from an external source", "collection.edit.tabs.source.external": "Šīs kolekcijas saturs tiek iegūts no ārēja avota", - + // "collection.edit.tabs.source.form.errors.oaiSource.required": "You must provide a set id of the target collection.", "collection.edit.tabs.source.form.errors.oaiSource.required": "Jums ir jānorāda noteikts id no mērķa kolekcijas", - + // "collection.edit.tabs.source.form.harvestType": "Content being harvested", "collection.edit.tabs.source.form.harvestType": "Saturs tiek iegūts", - + // "collection.edit.tabs.source.form.head": "Configure an external source", "collection.edit.tabs.source.form.head": "Konfigurēt ārējo avotu", - + // "collection.edit.tabs.source.form.metadataConfigId": "Metadata Format", "collection.edit.tabs.source.form.metadataConfigId": "Matadatu formāts", - + // "collection.edit.tabs.source.form.oaiSetId": "OAI specific set id", "collection.edit.tabs.source.form.oaiSetId": "OAI konkrētas kopas id", - + // "collection.edit.tabs.source.form.oaiSource": "OAI Provider", "collection.edit.tabs.source.form.oaiSource": "OAI Sniedzējs", - + // "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_BITSTREAMS": "Harvest metadata and bitstreams (requires ORE support)", "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_BITSTREAMS": "Ievākt metadatus un bitu straumes (nepieciešams ORE atbalsts)", - + // "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_REF": "Harvest metadata and references to bitstreams (requires ORE support)", "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_REF": "Ievākt metadatus un atsauces uz bitu straumēm (requires ORE support)", - + // "collection.edit.tabs.source.form.options.harvestType.METADATA_ONLY": "Harvest metadata only", "collection.edit.tabs.source.form.options.harvestType.METADATA_ONLY": "Iegūt tikai metadatus", - + // "collection.edit.tabs.source.head": "Content Source", "collection.edit.tabs.source.head": "Satura Avots", - + // "collection.edit.tabs.source.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", "collection.edit.tabs.source.notifications.discarded.content": "Jūsu veiktās izmaiņas tika atmestas. Lai atjaunotu izmaiņas, noklikšķiniet uz pogas 'Atsaukt'", - + // "collection.edit.tabs.source.notifications.discarded.title": "Changed discarded", "collection.edit.tabs.source.notifications.discarded.title": "Mainīts atmests", - + // "collection.edit.tabs.source.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", "collection.edit.tabs.source.notifications.invalid.content": "Jūsu veiktās izmaiņas netika saglabātas. Pirms saglabāšanas, lūdzu, pārliecinieties, vai visi lauki ir derīgi.", - + // "collection.edit.tabs.source.notifications.invalid.title": "Metadata invalid", "collection.edit.tabs.source.notifications.invalid.title": "Metadati nav derīgi", - + // "collection.edit.tabs.source.notifications.saved.content": "Your changes to this collection's content source were saved.", "collection.edit.tabs.source.notifications.saved.content": "Jūsu veiktās izmaiņas šīs kolekcijas satura avotā tika saglabātas.", - + // "collection.edit.tabs.source.notifications.saved.title": "Content Source saved", "collection.edit.tabs.source.notifications.saved.title": "Satura avots ir saglabāts", - + // "collection.edit.tabs.source.title": "Collection Edit - Content Source", "collection.edit.tabs.source.title": "Rediģēt Kolekciju - Satura Avots", - - - + + + // "collection.edit.template.add-button": "Add", // TODO New key - Add a translation "collection.edit.template.add-button": "Add", - + // "collection.edit.template.breadcrumbs": "Item template", // TODO New key - Add a translation "collection.edit.template.breadcrumbs": "Item template", - + // "collection.edit.template.cancel": "Cancel", // TODO New key - Add a translation "collection.edit.template.cancel": "Cancel", - + // "collection.edit.template.delete-button": "Delete", // TODO New key - Add a translation "collection.edit.template.delete-button": "Delete", - + // "collection.edit.template.edit-button": "Edit", // TODO New key - Add a translation "collection.edit.template.edit-button": "Edit", - + // "collection.edit.template.head": "Edit Template Item for Collection \"{{ collection }}\"", // TODO New key - Add a translation "collection.edit.template.head": "Edit Template Item for Collection \"{{ collection }}\"", - + // "collection.edit.template.label": "Template item", // TODO New key - Add a translation "collection.edit.template.label": "Template item", - + // "collection.edit.template.notifications.delete.error": "Failed to delete the item template", // TODO New key - Add a translation "collection.edit.template.notifications.delete.error": "Failed to delete the item template", - + // "collection.edit.template.notifications.delete.success": "Successfully deleted the item template", // TODO New key - Add a translation "collection.edit.template.notifications.delete.success": "Successfully deleted the item template", - + // "collection.edit.template.title": "Edit Template Item", // TODO New key - Add a translation "collection.edit.template.title": "Edit Template Item", - - - + + + // "collection.form.abstract": "Short Description", "collection.form.abstract": "Īss apraksts", - + // "collection.form.description": "Introductory text (HTML)", "collection.form.description": "Ievadteksts (HTML)", - + // "collection.form.errors.title.required": "Please enter a collection name", "collection.form.errors.title.required": "Lūdzu ievadiet kolekcijas nosaukumu", - + // "collection.form.license": "License", "collection.form.license": "Licence", - + // "collection.form.provenance": "Provenance", "collection.form.provenance": "Izcelsmes avots", - + // "collection.form.rights": "Copyright text (HTML)", "collection.form.rights": "Autortiesību teksts (HTML)", - + // "collection.form.tableofcontents": "News (HTML)", "collection.form.tableofcontents": "Jaunumi (HTML)", - + // "collection.form.title": "Name", "collection.form.title": "Nosaukums", - - - + + + // "collection.listelement.badge": "Collection", // TODO New key - Add a translation "collection.listelement.badge": "Collection", - - - + + + // "collection.page.browse.recent.head": "Recent Submissions", "collection.page.browse.recent.head": "Nesenie iesniegumi", - + // "collection.page.browse.recent.empty": "No items to show", "collection.page.browse.recent.empty": "Ieraksti nav atrasti", - + // "collection.page.edit": "Edit this collection", // TODO New key - Add a translation "collection.page.edit": "Edit this collection", - + // "collection.page.handle": "Permanent URI for this collection", "collection.page.handle": "Kolekcijas nemainīgs URI", - + // "collection.page.license": "License", "collection.page.license": "Licence", - + // "collection.page.news": "News", "collection.page.news": "Jaunumi", - - - + + + // "collection.select.confirm": "Confirm selected", "collection.select.confirm": "Apsitprināt izvēlēto", - + // "collection.select.empty": "No collections to show", "collection.select.empty": "Kolekcijas nav pieejamas", - + // "collection.select.table.title": "Title", "collection.select.table.title": "Nosaukums", - - - + + + // "collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.", "collection.source.update.notifications.error.content": "Norādītie iestatījumi ir pārbaudīti un nedarbojas.", - + // "collection.source.update.notifications.error.title": "Server Error", "collection.source.update.notifications.error.title": "Servera Kļūda", - - - + + + // "communityList.tabTitle": "DSpace - Community List", "communityList.tabTitle": "DSpace - Kategoriju Saraksts", - + // "communityList.title": "List of Communities", "communityList.title": "Kategoriju saraksts", - + // "communityList.showMore": "Show More", "communityList.showMore": "Rādīt Vairāk", - - - + + + // "community.create.head": "Create a Community", "community.create.head": "Izveidot Kategoriju", - + // "community.create.notifications.success": "Successfully created the Community", "community.create.notifications.success": "Kategorija tika veiksmīgi izveidota", - + // "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", "community.create.sub-head": "Izveidot Apakškategorija priekš Kategorijas {{ parent }}", - + // "community.curate.header": "Curate Community: {{community}}", // TODO New key - Add a translation "community.curate.header": "Curate Community: {{community}}", - + // "community.delete.cancel": "Cancel", "community.delete.cancel": "Atcelt", - + // "community.delete.confirm": "Confirm", "community.delete.confirm": "Apstiprināt", - + // "community.delete.head": "Delete Community", "community.delete.head": "Dzēst Kategoriju", - + // "community.delete.notification.fail": "Community could not be deleted", "community.delete.notification.fail": "Kategriju nav iespējams izdzēst", - + // "community.delete.notification.success": "Successfully deleted community", "community.delete.notification.success": "Veiksmīgi izdzēsta kategorija", - + // "community.delete.text": "Are you sure you want to delete community \"{{ dso }}\"", "community.delete.text": "Vai tiešām vēlaties dzēst kategoriju \"{{ dso }}\"", - + // "community.edit.delete": "Delete this community", "community.edit.delete": "Dzēst šo kategoriju", - + // "community.edit.head": "Edit Community", "community.edit.head": "Rediģēt Kategoriju", - + // "community.edit.breadcrumbs": "Edit Community", "community.edit.breadcrumbs": "Rediģēt Kategoriju", - - + + // "community.edit.logo.label": "Community logo", "community.edit.logo.label": "Kategorijas logotips", - + // "community.edit.logo.notifications.add.error": "Uploading Community logo failed. Please verify the content before retrying.", "community.edit.logo.notifications.add.error": "Kategorijas logotipa augšupielāde neizdevās. Pirms mēģināt vēlreiz, lūdzu, pārbaudiet saturu.", - + // "community.edit.logo.notifications.add.success": "Upload Community logo successful.", "community.edit.logo.notifications.add.success": "Kategorijas logotipa augšupielāde ir veiksmīga.", - + // "community.edit.logo.notifications.delete.success.title": "Logo deleted", "community.edit.logo.notifications.delete.success.title": "logotips ir izdzēsts", - + // "community.edit.logo.notifications.delete.success.content": "Successfully deleted the community's logo", "community.edit.logo.notifications.delete.success.content": "Kategorijas logotips ir veiksmīgi izdzēsts", - + // "community.edit.logo.notifications.delete.error.title": "Error deleting logo", "community.edit.logo.notifications.delete.error.title": "Izdzēšot logotipu, radās kļūda", - + // "community.edit.logo.upload": "Drop a Community Logo to upload", "community.edit.logo.upload": "Ievietot Kategorijas logotipu, lai augšupielādētu", - - - + + + // "community.edit.notifications.success": "Successfully edited the Community", "community.edit.notifications.success": "Kategorija tika veiksmīgi rediģēta", - + // "community.edit.notifications.unauthorized": "You do not have privileges to make this change", // TODO New key - Add a translation "community.edit.notifications.unauthorized": "You do not have privileges to make this change", - + // "community.edit.notifications.error": "An error occured while editing the Community", // TODO New key - Add a translation "community.edit.notifications.error": "An error occured while editing the Community", - + // "community.edit.return": "Return", "community.edit.return": "Atgriezties", - - - + + + // "community.edit.tabs.curate.head": "Curate", "community.edit.tabs.curate.head": "Pārvaldīt", - + // "community.edit.tabs.curate.title": "Community Edit - Curate", "community.edit.tabs.curate.title": "Rdiģēt Kategoriju - Pārvaldība", - + // "community.edit.tabs.metadata.head": "Edit Metadata", "community.edit.tabs.metadata.head": "Rediģēt Metadatus", - + // "community.edit.tabs.metadata.title": "Community Edit - Metadata", "community.edit.tabs.metadata.title": "Rediģēt Kategoriju - Metadati", - + // "community.edit.tabs.roles.head": "Assign Roles", "community.edit.tabs.roles.head": "Piešķirt Lomas", - + // "community.edit.tabs.roles.title": "Community Edit - Roles", "community.edit.tabs.roles.title": "Rediģēt Kategorju - Lomas", - + // "community.edit.tabs.authorizations.head": "Authorizations", // TODO New key - Add a translation "community.edit.tabs.authorizations.head": "Authorizations", - + // "community.edit.tabs.authorizations.title": "Community Edit - Authorizations", // TODO New key - Add a translation "community.edit.tabs.authorizations.title": "Community Edit - Authorizations", - - - + + + // "community.listelement.badge": "Community", // TODO New key - Add a translation "community.listelement.badge": "Community", - - - + + + // "comcol-role.edit.no-group": "None", // TODO New key - Add a translation "comcol-role.edit.no-group": "None", - + // "comcol-role.edit.create": "Create", // TODO New key - Add a translation "comcol-role.edit.create": "Create", - + // "comcol-role.edit.restrict": "Restrict", // TODO New key - Add a translation "comcol-role.edit.restrict": "Restrict", - + // "comcol-role.edit.delete": "Delete", // TODO New key - Add a translation "comcol-role.edit.delete": "Delete", - - + + // "comcol-role.edit.community-admin.name": "Administrators", // TODO New key - Add a translation "comcol-role.edit.community-admin.name": "Administrators", - + // "comcol-role.edit.collection-admin.name": "Administrators", // TODO New key - Add a translation "comcol-role.edit.collection-admin.name": "Administrators", - - + + // "comcol-role.edit.community-admin.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).", // TODO New key - Add a translation "comcol-role.edit.community-admin.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).", - + // "comcol-role.edit.collection-admin.description": "Collection administrators decide who can submit items to the collection, edit item metadata (after submission), and add (map) existing items from other collections to this collection (subject to authorization for that collection).", // TODO New key - Add a translation "comcol-role.edit.collection-admin.description": "Collection administrators decide who can submit items to the collection, edit item metadata (after submission), and add (map) existing items from other collections to this collection (subject to authorization for that collection).", - - + + // "comcol-role.edit.submitters.name": "Submitters", // TODO New key - Add a translation "comcol-role.edit.submitters.name": "Submitters", - + // "comcol-role.edit.submitters.description": "The E-People and Groups that have permission to submit new items to this collection.", // TODO New key - Add a translation "comcol-role.edit.submitters.description": "The E-People and Groups that have permission to submit new items to this collection.", - - + + // "comcol-role.edit.item_read.name": "Default item read access", // TODO New key - Add a translation "comcol-role.edit.item_read.name": "Default item read access", - + // "comcol-role.edit.item_read.description": "E-People and Groups that can read new items submitted to this collection. Changes to this role are not retroactive. Existing items in the system will still be viewable by those who had read access at the time of their addition.", // TODO New key - Add a translation "comcol-role.edit.item_read.description": "E-People and Groups that can read new items submitted to this collection. Changes to this role are not retroactive. Existing items in the system will still be viewable by those who had read access at the time of their addition.", - + // "comcol-role.edit.item_read.anonymous-group": "Default read for incoming items is currently set to Anonymous.", // TODO New key - Add a translation "comcol-role.edit.item_read.anonymous-group": "Default read for incoming items is currently set to Anonymous.", - - + + // "comcol-role.edit.bitstream_read.name": "Default bitstream read access", // TODO New key - Add a translation "comcol-role.edit.bitstream_read.name": "Default bitstream read access", - + // "comcol-role.edit.bitstream_read.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).", // TODO New key - Add a translation "comcol-role.edit.bitstream_read.description": "Community administrators can create sub-communities or collections, and manage or assign management for those sub-communities or collections. In addition, they decide who can submit items to any sub-collections, edit item metadata (after submission), and add (map) existing items from other collections (subject to authorization).", - + // "comcol-role.edit.bitstream_read.anonymous-group": "Default read for incoming bitstreams is currently set to Anonymous.", // TODO New key - Add a translation "comcol-role.edit.bitstream_read.anonymous-group": "Default read for incoming bitstreams is currently set to Anonymous.", - - + + // "comcol-role.edit.editor.name": "Editors", // TODO New key - Add a translation "comcol-role.edit.editor.name": "Editors", - + // "comcol-role.edit.editor.description": "Editors are able to edit the metadata of incoming submissions, and then accept or reject them.", // TODO New key - Add a translation "comcol-role.edit.editor.description": "Editors are able to edit the metadata of incoming submissions, and then accept or reject them.", - - + + // "comcol-role.edit.finaleditor.name": "Final editors", // TODO New key - Add a translation "comcol-role.edit.finaleditor.name": "Final editors", - + // "comcol-role.edit.finaleditor.description": "Final editors are able to edit the metadata of incoming submissions, but will not be able to reject them.", // TODO New key - Add a translation "comcol-role.edit.finaleditor.description": "Final editors are able to edit the metadata of incoming submissions, but will not be able to reject them.", - - + + // "comcol-role.edit.reviewer.name": "Reviewers", // TODO New key - Add a translation "comcol-role.edit.reviewer.name": "Reviewers", - + // "comcol-role.edit.reviewer.description": "Reviewers are able to accept or reject incoming submissions. However, they are not able to edit the submission's metadata.", // TODO New key - Add a translation "comcol-role.edit.reviewer.description": "Reviewers are able to accept or reject incoming submissions. However, they are not able to edit the submission's metadata.", - - - + + + // "community.form.abstract": "Short Description", "community.form.abstract": "Īss apraksts", - + // "community.form.description": "Introductory text (HTML)", "community.form.description": "Ievadteksts (HTML)", - + // "community.form.errors.title.required": "Please enter a community name", "community.form.errors.title.required": "Lūdzu ievadiet kategorijas nosaukumu", - + // "community.form.rights": "Copyright text (HTML)", "community.form.rights": "Autortiesību teksts (HTML)", - + // "community.form.tableofcontents": "News (HTML)", "community.form.tableofcontents": "Jaunumi (HTML)", - + // "community.form.title": "Name", "community.form.title": "Nosaukums", - + // "community.page.edit": "Edit this community", // TODO New key - Add a translation "community.page.edit": "Edit this community", - + // "community.page.handle": "Permanent URI for this community", "community.page.handle": "Kategorijas nemainīgs URI", - + // "community.page.license": "License", "community.page.license": "Licence", - + // "community.page.news": "News", "community.page.news": "Jaunumi", - + // "community.all-lists.head": "Subcommunities and Collections", "community.all-lists.head": "Apakškategorijas un Kolekcijas", - + // "community.sub-collection-list.head": "Collections of this Community", "community.sub-collection-list.head": "Kolekcijas no šīs kategorijas", - + // "community.sub-community-list.head": "Communities of this Community", "community.sub-community-list.head": "Šīs kategorijas apakš-kategorijas", - - - + + + // "cookies.consent.accept-all": "Accept all", // TODO New key - Add a translation "cookies.consent.accept-all": "Accept all", - + // "cookies.consent.accept-selected": "Accept selected", // TODO New key - Add a translation "cookies.consent.accept-selected": "Accept selected", - + // "cookies.consent.app.opt-out.description": "This app is loaded by default (but you can opt out)", // TODO New key - Add a translation "cookies.consent.app.opt-out.description": "This app is loaded by default (but you can opt out)", - + // "cookies.consent.app.opt-out.title": "(opt-out)", // TODO New key - Add a translation "cookies.consent.app.opt-out.title": "(opt-out)", - + // "cookies.consent.app.purpose": "purpose", // TODO New key - Add a translation "cookies.consent.app.purpose": "purpose", - + // "cookies.consent.app.required.description": "This application is always required", // TODO New key - Add a translation "cookies.consent.app.required.description": "This application is always required", - + // "cookies.consent.app.required.title": "(always required)", // TODO New key - Add a translation "cookies.consent.app.required.title": "(always required)", - + // "cookies.consent.update": "There were changes since your last visit, please update your consent.", // TODO New key - Add a translation "cookies.consent.update": "There were changes since your last visit, please update your consent.", - + // "cookies.consent.close": "Close", // TODO New key - Add a translation "cookies.consent.close": "Close", - + // "cookies.consent.decline": "Decline", // TODO New key - Add a translation "cookies.consent.decline": "Decline", - + // "cookies.consent.content-notice.description": "We collect and process your personal information for the following purposes: Authentication, Preferences, Acknowledgement and Statistics.
To learn more, please read our {privacyPolicy}.", // TODO New key - Add a translation "cookies.consent.content-notice.description": "We collect and process your personal information for the following purposes: Authentication, Preferences, Acknowledgement and Statistics.
To learn more, please read our {privacyPolicy}.", - + // "cookies.consent.content-notice.learnMore": "Customize", // TODO New key - Add a translation "cookies.consent.content-notice.learnMore": "Customize", - + // "cookies.consent.content-modal.description": "Here you can see and customize the information that we collect about you.", // TODO New key - Add a translation "cookies.consent.content-modal.description": "Here you can see and customize the information that we collect about you.", - + // "cookies.consent.content-modal.privacy-policy.name": "privacy policy", // TODO New key - Add a translation "cookies.consent.content-modal.privacy-policy.name": "privacy policy", - + // "cookies.consent.content-modal.privacy-policy.text": "To learn more, please read our {privacyPolicy}.", // TODO New key - Add a translation "cookies.consent.content-modal.privacy-policy.text": "To learn more, please read our {privacyPolicy}.", - + // "cookies.consent.content-modal.title": "Information that we collect", // TODO New key - Add a translation "cookies.consent.content-modal.title": "Information that we collect", - - - + + + // "cookies.consent.app.title.authentication": "Authentication", // TODO New key - Add a translation "cookies.consent.app.title.authentication": "Authentication", - + // "cookies.consent.app.description.authentication": "Required for signing you in", // TODO New key - Add a translation "cookies.consent.app.description.authentication": "Required for signing you in", - - + + // "cookies.consent.app.title.preferences": "Preferences", // TODO New key - Add a translation "cookies.consent.app.title.preferences": "Preferences", - + // "cookies.consent.app.description.preferences": "Required for saving your preferences", // TODO New key - Add a translation "cookies.consent.app.description.preferences": "Required for saving your preferences", - - - + + + // "cookies.consent.app.title.acknowledgement": "Acknowledgement", // TODO New key - Add a translation "cookies.consent.app.title.acknowledgement": "Acknowledgement", - + // "cookies.consent.app.description.acknowledgement": "Required for saving your acknowledgements and consents", // TODO New key - Add a translation "cookies.consent.app.description.acknowledgement": "Required for saving your acknowledgements and consents", - - - + + + // "cookies.consent.app.title.google-analytics": "Google Analytics", // TODO New key - Add a translation "cookies.consent.app.title.google-analytics": "Google Analytics", - + // "cookies.consent.app.description.google-analytics": "Allows us to track statistical data", // TODO New key - Add a translation "cookies.consent.app.description.google-analytics": "Allows us to track statistical data", - - - + + + // "cookies.consent.purpose.functional": "Functional", // TODO New key - Add a translation "cookies.consent.purpose.functional": "Functional", - + // "cookies.consent.purpose.statistical": "Statistical", // TODO New key - Add a translation "cookies.consent.purpose.statistical": "Statistical", - - + + // "curation-task.task.checklinks.label": "Check Links in Metadata", // TODO New key - Add a translation "curation-task.task.checklinks.label": "Check Links in Metadata", - + // "curation-task.task.noop.label": "NOOP", // TODO New key - Add a translation "curation-task.task.noop.label": "NOOP", - + // "curation-task.task.profileformats.label": "Profile Bitstream Formats", // TODO New key - Add a translation "curation-task.task.profileformats.label": "Profile Bitstream Formats", - + // "curation-task.task.requiredmetadata.label": "Check for Required Metadata", // TODO New key - Add a translation "curation-task.task.requiredmetadata.label": "Check for Required Metadata", - + // "curation-task.task.translate.label": "Microsoft Translator", // TODO New key - Add a translation "curation-task.task.translate.label": "Microsoft Translator", - + // "curation-task.task.vscan.label": "Virus Scan", // TODO New key - Add a translation "curation-task.task.vscan.label": "Virus Scan", - - - + + + // "curation.form.task-select.label": "Task:", // TODO New key - Add a translation "curation.form.task-select.label": "Task:", - + // "curation.form.submit": "Start", // TODO New key - Add a translation "curation.form.submit": "Start", - + // "curation.form.submit.success.head": "The curation task has been started successfully", // TODO New key - Add a translation "curation.form.submit.success.head": "The curation task has been started successfully", - + // "curation.form.submit.success.content": "You will be redirected to the corresponding process page.", // TODO New key - Add a translation "curation.form.submit.success.content": "You will be redirected to the corresponding process page.", - + // "curation.form.submit.error.head": "Running the curation task failed", // TODO New key - Add a translation "curation.form.submit.error.head": "Running the curation task failed", - + // "curation.form.submit.error.content": "An error occured when trying to start the curation task.", // TODO New key - Add a translation "curation.form.submit.error.content": "An error occured when trying to start the curation task.", - + // "curation.form.handle.label": "Handle:", // TODO New key - Add a translation "curation.form.handle.label": "Handle:", - + // "curation.form.handle.hint": "Hint: Enter [your-handle-prefix]/0 to run a task across entire site (not all tasks may support this capability)", // TODO New key - Add a translation "curation.form.handle.hint": "Hint: Enter [your-handle-prefix]/0 to run a task across entire site (not all tasks may support this capability)", - - - + + + // "dso-selector.create.collection.head": "New collection", "dso-selector.create.collection.head": "Jauna kolekcija", - + // "dso-selector.create.collection.sub-level": "Create a new collection in", // TODO New key - Add a translation "dso-selector.create.collection.sub-level": "Create a new collection in", - + // "dso-selector.create.community.head": "New community", "dso-selector.create.community.head": "Jauna kategorija", - + // "dso-selector.create.community.sub-level": "Create a new community in", "dso-selector.create.community.sub-level": "Izveidot jaunu kategroiju iekš", - + // "dso-selector.create.community.top-level": "Create a new top-level community", "dso-selector.create.community.top-level": "Izveidot jaunu augstākā līmeņa kategoriju", - + // "dso-selector.create.item.head": "New item", "dso-selector.create.item.head": "Jauns materiāls", - + // "dso-selector.create.item.sub-level": "Create a new item in", // TODO New key - Add a translation "dso-selector.create.item.sub-level": "Create a new item in", - + // "dso-selector.create.submission.head": "New submission", // TODO New key - Add a translation "dso-selector.create.submission.head": "New submission", - + // "dso-selector.edit.collection.head": "Edit collection", "dso-selector.edit.collection.head": "Rediģēt kolekciju", - + // "dso-selector.edit.community.head": "Edit community", "dso-selector.edit.community.head": "Rediģēt kategoriju", - + // "dso-selector.edit.item.head": "Edit item", "dso-selector.edit.item.head": "Rediģēt matriālu", - + // "dso-selector.export-metadata.dspaceobject.head": "Export metadata from", // TODO New key - Add a translation "dso-selector.export-metadata.dspaceobject.head": "Export metadata from", - + // "dso-selector.no-results": "No {{ type }} found", "dso-selector.no-results": "Nav atrasts {{ type }}", - + // "dso-selector.placeholder": "Search for a {{ type }}", "dso-selector.placeholder": "Meklēt {{ type }}", - - - + + + // "confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}", // TODO New key - Add a translation "confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}", - + // "confirmation-modal.export-metadata.info": "Are you sure you want to export metadata for {{ dsoName }}", // TODO New key - Add a translation "confirmation-modal.export-metadata.info": "Are you sure you want to export metadata for {{ dsoName }}", - + // "confirmation-modal.export-metadata.cancel": "Cancel", // TODO New key - Add a translation "confirmation-modal.export-metadata.cancel": "Cancel", - + // "confirmation-modal.export-metadata.confirm": "Export", // TODO New key - Add a translation "confirmation-modal.export-metadata.confirm": "Export", - + // "confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"", // TODO New key - Add a translation "confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"", - + // "confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"", // TODO New key - Add a translation "confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"", - + // "confirmation-modal.delete-eperson.cancel": "Cancel", // TODO New key - Add a translation "confirmation-modal.delete-eperson.cancel": "Cancel", - + // "confirmation-modal.delete-eperson.confirm": "Delete", // TODO New key - Add a translation "confirmation-modal.delete-eperson.confirm": "Delete", - - + + // "error.bitstream": "Error fetching bitstream", "error.bitstream": "Radās kļūda, saņemot bitu straumi", - + // "error.browse-by": "Error fetching items", "error.browse-by": "Materiālu ielasīšanas kļūda", - + // "error.collection": "Error fetching collection", "error.collection": "Kolekciju ielasīšanas kļūda", - + // "error.collections": "Error fetching collections", "error.collections": "Kolekciju ielasīšanas kļūda", - + // "error.community": "Error fetching community", "error.community": "Kategorijas ielasīšanas kļūda", - + // "error.identifier": "No item found for the identifier", "error.identifier": "Netika atrasti materiāli dotajam identifikatoram", - + // "error.default": "Error", "error.default": "Kļūda", - + // "error.item": "Error fetching item", "error.item": "Materiāla ielasīšanas kļūda", - + // "error.items": "Error fetching items", "error.items": "Materiālu ielasīšanas kļūda", - + // "error.objects": "Error fetching objects", "error.objects": "Objektu ielasīšanas kļūda", - + // "error.recent-submissions": "Error fetching recent submissions", "error.recent-submissions": "Neseno iesniegumu ielasīšanas kļūda", - + // "error.search-results": "Error fetching search results", "error.search-results": "Meklēšanas rezultātu ielasīšanas kļūda", - + // "error.sub-collections": "Error fetching sub-collections", "error.sub-collections": "Kļūda ielasot apakškolekcijas", - + // "error.sub-communities": "Error fetching sub-communities", "error.sub-communities": "Apakškategorijas ielasīšanas kļūda", - + // "error.submission.sections.init-form-error": "An error occurred during section initialize, please check your input-form configuration. Details are below :

", "error.submission.sections.init-form-error": "Sadaļas inicializācijas laikā radās kļūda. Lūdzu, pārbaudiet ievades formas konfigurāciju. Sīkāka informācija ir sniegta zemāk :

", - + // "error.top-level-communities": "Error fetching top-level communities", "error.top-level-communities": "Kļūda ielasot augstākā līmeņa kategorijas", - + // "error.validation.license.notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", "error.validation.license.notgranted": "Jums ir jānodrošina šī licence, lai pabeigtu iesniegšanu. Ja šobrīd nevarat piešķirt šo licenci, varat saglabāt savu darbu un atgriezties vēlāk vai noņemt iesniegšanu.", - + // "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", "error.validation.pattern": "Šo ievadi ierobežo pašreizējais modelis: {{ pattern }}.", - + // "error.validation.filerequired": "The file upload is mandatory", "error.validation.filerequired": "Faila augšupielāde ir obligāta", - - - + + + // "file-section.error.header": "Error obtaining files for this item", // TODO New key - Add a translation "file-section.error.header": "Error obtaining files for this item", - - - + + + // "footer.copyright": "copyright © 2002-{{ year }}", "footer.copyright": "copyright © 2002-{{ year }}", - + // "footer.link.dspace": "DSpace software", "footer.link.dspace": "DSpace software", - + // "footer.link.lyrasis": "LYRASIS", "footer.link.lyrasis": "LYRASIS", - + // "footer.link.cookies": "Cookie settings", // TODO New key - Add a translation "footer.link.cookies": "Cookie settings", - + // "footer.link.privacy-policy": "Privacy policy", // TODO New key - Add a translation "footer.link.privacy-policy": "Privacy policy", - + // "footer.link.end-user-agreement":"End User Agreement", // TODO New key - Add a translation "footer.link.end-user-agreement":"End User Agreement", - - - + + + // "forgot-email.form.header": "Forgot Password", // TODO New key - Add a translation "forgot-email.form.header": "Forgot Password", - + // "forgot-email.form.info": "Enter Register an account to subscribe to collections for email updates, and submit new items to DSpace.", // TODO New key - Add a translation "forgot-email.form.info": "Enter Register an account to subscribe to collections for email updates, and submit new items to DSpace.", - + // "forgot-email.form.email": "Email Address *", // TODO New key - Add a translation "forgot-email.form.email": "Email Address *", - + // "forgot-email.form.email.error.required": "Please fill in an email address", // TODO New key - Add a translation "forgot-email.form.email.error.required": "Please fill in an email address", - + // "forgot-email.form.email.error.pattern": "Please fill in a valid email address", // TODO New key - Add a translation "forgot-email.form.email.error.pattern": "Please fill in a valid email address", - + // "forgot-email.form.email.hint": "This address will be verified and used as your login name.", // TODO New key - Add a translation "forgot-email.form.email.hint": "This address will be verified and used as your login name.", - + // "forgot-email.form.submit": "Submit", // TODO New key - Add a translation "forgot-email.form.submit": "Submit", - + // "forgot-email.form.success.head": "Verification email sent", // TODO New key - Add a translation "forgot-email.form.success.head": "Verification email sent", - + // "forgot-email.form.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", // TODO New key - Add a translation "forgot-email.form.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", - + // "forgot-email.form.error.head": "Error when trying to register email", // TODO New key - Add a translation "forgot-email.form.error.head": "Error when trying to register email", - + // "forgot-email.form.error.content": "An error occured when registering the following email address: {{ email }}", // TODO New key - Add a translation "forgot-email.form.error.content": "An error occured when registering the following email address: {{ email }}", - - - + + + // "forgot-password.title": "Forgot Password", // TODO New key - Add a translation "forgot-password.title": "Forgot Password", - + // "forgot-password.form.head": "Forgot Password", // TODO New key - Add a translation "forgot-password.form.head": "Forgot Password", - + // "forgot-password.form.info": "Enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", // TODO New key - Add a translation "forgot-password.form.info": "Enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", - + // "forgot-password.form.card.security": "Security", // TODO New key - Add a translation "forgot-password.form.card.security": "Security", - + // "forgot-password.form.identification.header": "Identify", // TODO New key - Add a translation "forgot-password.form.identification.header": "Identify", - + // "forgot-password.form.identification.email": "Email address: ", // TODO New key - Add a translation "forgot-password.form.identification.email": "Email address: ", - + // "forgot-password.form.label.password": "Password", // TODO New key - Add a translation "forgot-password.form.label.password": "Password", - + // "forgot-password.form.label.passwordrepeat": "Retype to confirm", // TODO New key - Add a translation "forgot-password.form.label.passwordrepeat": "Retype to confirm", - + // "forgot-password.form.error.empty-password": "Please enter a password in the box below.", // TODO New key - Add a translation "forgot-password.form.error.empty-password": "Please enter a password in the box below.", - + // "forgot-password.form.error.matching-passwords": "The passwords do not match.", // TODO New key - Add a translation "forgot-password.form.error.matching-passwords": "The passwords do not match.", - + // "forgot-password.form.error.password-length": "The password should be at least 6 characters long.", // TODO New key - Add a translation "forgot-password.form.error.password-length": "The password should be at least 6 characters long.", - + // "forgot-password.form.notification.error.title": "Error when trying to submit new password", // TODO New key - Add a translation "forgot-password.form.notification.error.title": "Error when trying to submit new password", - + // "forgot-password.form.notification.success.content": "The password reset was successful. You have been logged in as the created user.", // TODO New key - Add a translation "forgot-password.form.notification.success.content": "The password reset was successful. You have been logged in as the created user.", - + // "forgot-password.form.notification.success.title": "Password reset completed", // TODO New key - Add a translation "forgot-password.form.notification.success.title": "Password reset completed", - + // "forgot-password.form.submit": "Submit password", // TODO New key - Add a translation "forgot-password.form.submit": "Submit password", - - - + + + // "form.add": "Add", "form.add": "Pievienot", - + // "form.add-help": "Click here to add the current entry and to add another one", "form.add-help": "Noklikšķiniet šeit, lai pievienotu tekošo elementu un lai pievienotu vēl vienu", - + // "form.cancel": "Cancel", "form.cancel": "Atcelt", - + // "form.clear": "Clear", "form.clear": "Notīrīt", - + // "form.clear-help": "Click here to remove the selected value", "form.clear-help": "Noklikšķiniet šeit, lai dzēstu atlasīto vērtību", - + // "form.edit": "Edit", "form.edit": "Rediģēt", - + // "form.edit-help": "Click here to edit the selected value", "form.edit-help": "Noklikšķiniet šeit, lai rediģēt atlasīto vērtību", - + // "form.first-name": "First name", "form.first-name": "Vārds", - + // "form.group-collapse": "Collapse", "form.group-collapse": "Sakļaut", - + // "form.group-collapse-help": "Click here to collapse", "form.group-collapse-help": "Noklikšķiniet šeit, lai sakļautu", - + // "form.group-expand": "Expand", "form.group-expand": "Izvērst", - + // "form.group-expand-help": "Click here to expand and add more elements", "form.group-expand-help": "Noklikšķiniet šeit, lai izvērstu un pievienotu citus elementus", - + // "form.last-name": "Last name", "form.last-name": "Uzvārds", - + // "form.loading": "Loading...", "form.loading": "Notiek ielāde...", - + // "form.lookup": "Lookup", "form.lookup": "Atrast", - + // "form.lookup-help": "Click here to look up an existing relation", "form.lookup-help": "Noklikšķiniet šeit, lai meklētu esošu saistību", - + // "form.no-results": "No results found", "form.no-results": "Rezultāti netika atrasti", - + // "form.no-value": "No value entered", "form.no-value": "Vērtība netika ievadīta", - + // "form.other-information": {}, "form.other-information": {}, - + // "form.remove": "Remove", "form.remove": "Dzēst", - + // "form.save": "Save", "form.save": "Saglabāt", - + // "form.save-help": "Save changes", "form.save-help": "Saglabāt izmaiņas", - + // "form.search": "Search", "form.search": "Meklēt", - + // "form.search-help": "Click here to look for an existing correspondence", // TODO Source message changed - Revise the translation "form.search-help": "Noklikšķiniet šeit, lai meklētu esošu korespondenci", - + // "form.submit": "Submit", "form.submit": "Iesniegt", - - - + + + // "home.description": "", "home.description": "", - + // "home.breadcrumbs": "Home", // TODO New key - Add a translation "home.breadcrumbs": "Home", - + // "home.title": "DSpace Angular :: Home", "home.title": "DSpace Angular :: Sākums", - + // "home.top-level-communities.head": "Communities in DSpace", "home.top-level-communities.head": "DSpace Kategorijas", - + // "home.top-level-communities.help": "Select a community to browse its collections.", "home.top-level-communities.help": "Izvēlieties kategoriju, lai pārlūkotu tās kolekcijas.", - - - + + + // "info.end-user-agreement.accept": "I have read and I agree to the End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.accept": "I have read and I agree to the End User Agreement", - + // "info.end-user-agreement.accept.error": "An error occurred accepting the End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.accept.error": "An error occurred accepting the End User Agreement", - + // "info.end-user-agreement.accept.success": "Successfully updated the End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.accept.success": "Successfully updated the End User Agreement", - + // "info.end-user-agreement.breadcrumbs": "End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.breadcrumbs": "End User Agreement", - + // "info.end-user-agreement.buttons.cancel": "Cancel", // TODO New key - Add a translation "info.end-user-agreement.buttons.cancel": "Cancel", - + // "info.end-user-agreement.buttons.save": "Save", // TODO New key - Add a translation "info.end-user-agreement.buttons.save": "Save", - + // "info.end-user-agreement.head": "End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.head": "End User Agreement", - + // "info.end-user-agreement.title": "End User Agreement", // TODO New key - Add a translation "info.end-user-agreement.title": "End User Agreement", - + // "info.privacy.breadcrumbs": "Privacy Statement", // TODO New key - Add a translation "info.privacy.breadcrumbs": "Privacy Statement", - + // "info.privacy.head": "Privacy Statement", // TODO New key - Add a translation "info.privacy.head": "Privacy Statement", - + // "info.privacy.title": "Privacy Statement", // TODO New key - Add a translation "info.privacy.title": "Privacy Statement", - - - + + + // "item.alerts.private": "This item is private", // TODO New key - Add a translation "item.alerts.private": "This item is private", - + // "item.alerts.withdrawn": "This item has been withdrawn", // TODO New key - Add a translation "item.alerts.withdrawn": "This item has been withdrawn", - - - + + + // "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", // TODO New key - Add a translation "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", - + // "item.edit.authorizations.title": "Edit item's Policies", // TODO New key - Add a translation "item.edit.authorizations.title": "Edit item's Policies", - - - + + + // "item.badge.private": "Private", // TODO New key - Add a translation "item.badge.private": "Private", - + // "item.badge.withdrawn": "Withdrawn", // TODO New key - Add a translation "item.badge.withdrawn": "Withdrawn", - - - + + + // "item.bitstreams.upload.bundle": "Bundle", "item.bitstreams.upload.bundle": "Kopiens", - + // "item.bitstreams.upload.bundle.placeholder": "Select a bundle", "item.bitstreams.upload.bundle.placeholder": "Izvēlēties kopienu", - + // "item.bitstreams.upload.bundle.new": "Create bundle", "item.bitstreams.upload.bundle.new": "Izveidot kopienu", - + // "item.bitstreams.upload.bundles.empty": "This item doesn\'t contain any bundles to upload a bitstream to.", "item.bitstreams.upload.bundles.empty": "Materiālā nav neviena kopiena ko augšupielādēt bitu straumē.", - + // "item.bitstreams.upload.cancel": "Cancel", "item.bitstreams.upload.cancel": "Atcelt", - + // "item.bitstreams.upload.drop-message": "Drop a file to upload", "item.bitstreams.upload.drop-message": "Nomest failu augšupielādei", - + // "item.bitstreams.upload.item": "Item: ", "item.bitstreams.upload.item": "Materiāls: ", - + // "item.bitstreams.upload.notifications.bundle.created.content": "Successfully created new bundle.", "item.bitstreams.upload.notifications.bundle.created.content": "Veiksmīgi izveidota kopiena.", - + // "item.bitstreams.upload.notifications.bundle.created.title": "Created bundle", "item.bitstreams.upload.notifications.bundle.created.title": "Izveidot kopienu", - + // "item.bitstreams.upload.notifications.upload.failed": "Upload failed. Please verify the content before retrying.", "item.bitstreams.upload.notifications.upload.failed": "Augšupielāde neizdevās. Lūdzu pārbaudi saturu pirms mēģini vēlreiz.", - + // "item.bitstreams.upload.title": "Upload bitstream", "item.bitstreams.upload.title": "Augšupielādēt bitu straumi", - - - + + + // "item.edit.bitstreams.bundle.edit.buttons.upload": "Upload", "item.edit.bitstreams.bundle.edit.buttons.upload": "Augšupielādēt", - + // "item.edit.bitstreams.bundle.displaying": "Currently displaying {{ amount }} bitstreams of {{ total }}.", "item.edit.bitstreams.bundle.displaying": "Šobrīd rāda {{ amount }} bitu straumes no {{ total }}.", - + // "item.edit.bitstreams.bundle.load.all": "Load all ({{ total }})", "item.edit.bitstreams.bundle.load.all": "Ielādēt visus ({{ total }})", - + // "item.edit.bitstreams.bundle.load.more": "Load more", "item.edit.bitstreams.bundle.load.more": "Ielādēt vēl", - + // "item.edit.bitstreams.bundle.name": "BUNDLE: {{ name }}", "item.edit.bitstreams.bundle.name": "KOPIENA: {{ name }}", - + // "item.edit.bitstreams.discard-button": "Discard", "item.edit.bitstreams.discard-button": "Atsaukt", - + // "item.edit.bitstreams.edit.buttons.download": "Download", "item.edit.bitstreams.edit.buttons.download": "Lejupielādēt", - + // "item.edit.bitstreams.edit.buttons.drag": "Drag", "item.edit.bitstreams.edit.buttons.drag": "Vilkt", - + // "item.edit.bitstreams.edit.buttons.edit": "Edit", "item.edit.bitstreams.edit.buttons.edit": "Rediģēt", - + // "item.edit.bitstreams.edit.buttons.remove": "Remove", "item.edit.bitstreams.edit.buttons.remove": "Noņemt", - + // "item.edit.bitstreams.edit.buttons.undo": "Undo changes", "item.edit.bitstreams.edit.buttons.undo": "Atlikt izmaiņas", - + // "item.edit.bitstreams.empty": "This item doesn't contain any bitstreams. Click the upload button to create one.", "item.edit.bitstreams.empty": "Šis materiāls nesatur bitu straumi. Nospied augšupielādes pogu, lai izveidotu jaunu.", - + // "item.edit.bitstreams.headers.actions": "Actions", "item.edit.bitstreams.headers.actions": "Darbības", - + // "item.edit.bitstreams.headers.bundle": "Bundle", "item.edit.bitstreams.headers.bundle": "Kopiena", - + // "item.edit.bitstreams.headers.description": "Description", "item.edit.bitstreams.headers.description": "Apraksts", - + // "item.edit.bitstreams.headers.format": "Format", "item.edit.bitstreams.headers.format": "Formāts", - + // "item.edit.bitstreams.headers.name": "Name", "item.edit.bitstreams.headers.name": "Nosaukums", - + // "item.edit.bitstreams.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", "item.edit.bitstreams.notifications.discarded.content": "Jūsu veiktās izmaiņas tika atsaukt. Lai atjaunotu izmaiņas, noklikšķiniet uz pogas Atsaukt", - + // "item.edit.bitstreams.notifications.discarded.title": "Changes discarded", "item.edit.bitstreams.notifications.discarded.title": "Izmaiņas atsauktas", - + // "item.edit.bitstreams.notifications.move.failed.title": "Error moving bitstreams", "item.edit.bitstreams.notifications.move.failed.title": "Radās kļūda pārvietojot bitu straumi", - + // "item.edit.bitstreams.notifications.move.saved.content": "Your move changes to this item's bitstreams and bundles have been saved.", "item.edit.bitstreams.notifications.move.saved.content": "Jūsu pārvietošanas izmaiņas materiāla bitu straumē un kopienā ir saglabātas.", - + // "item.edit.bitstreams.notifications.move.saved.title": "Move changes saved", "item.edit.bitstreams.notifications.move.saved.title": "Pārvietošanas izmaiņas saglabātas", - + // "item.edit.bitstreams.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", "item.edit.bitstreams.notifications.outdated.content": "Materiāls, kurā pašlaik strādājat, ir mainījis kāds cits lietotājs. Jūsu pašreizējās izmaiņas tiek atmestas, lai novērstu konfliktus", - + // "item.edit.bitstreams.notifications.outdated.title": "Changes outdated", "item.edit.bitstreams.notifications.outdated.title": "Novecojušas izmaiņas", - + // "item.edit.bitstreams.notifications.remove.failed.title": "Error deleting bitstream", "item.edit.bitstreams.notifications.remove.failed.title": "Radās kļūda dzēšot bitu straumi", - + // "item.edit.bitstreams.notifications.remove.saved.content": "Your removal changes to this item's bitstreams have been saved.", "item.edit.bitstreams.notifications.remove.saved.content": "Jūsu materiāla bitu straumes izņemšanas izmaiņas ir saglabātas.", - + // "item.edit.bitstreams.notifications.remove.saved.title": "Removal changes saved", "item.edit.bitstreams.notifications.remove.saved.title": "Izņemšanas izmaiņas ir saglabātas", - + // "item.edit.bitstreams.reinstate-button": "Undo", "item.edit.bitstreams.reinstate-button": "Atlikt", - + // "item.edit.bitstreams.save-button": "Save", "item.edit.bitstreams.save-button": "Saglabāt", - + // "item.edit.bitstreams.upload-button": "Upload", "item.edit.bitstreams.upload-button": "Augšupielādēt", - - - + + + // "item.edit.delete.cancel": "Cancel", "item.edit.delete.cancel": "Atcelt", - + // "item.edit.delete.confirm": "Delete", "item.edit.delete.confirm": "Dzēst", - + // "item.edit.delete.description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", "item.edit.delete.description": "Vai esat drošs, ka pilnībā vēlaties izdzēst šo ierakstu? Uzmanību: Ieraksta kopija netiks saglabāta.", - + // "item.edit.delete.error": "An error occurred while deleting the item", "item.edit.delete.error": "Radās kļūda dzēšot materiālus", - + // "item.edit.delete.header": "Delete item: {{ id }}", "item.edit.delete.header": "Dzēst materiālu: {{ id }}", - + // "item.edit.delete.success": "The item has been deleted", "item.edit.delete.success": "Materiāls ir izdzēsts", - + // "item.edit.head": "Edit Item", "item.edit.head": "Rediģēt Materiālu", - + // "item.edit.breadcrumbs": "Edit Item", "item.edit.breadcrumbs": "Rediģēt Materiālu", - - + + // "item.edit.tabs.mapper.head": "Collection Mapper", // TODO New key - Add a translation "item.edit.tabs.mapper.head": "Collection Mapper", - + // "item.edit.tabs.item-mapper.title": "Item Edit - Collection Mapper", // TODO New key - Add a translation "item.edit.tabs.item-mapper.title": "Item Edit - Collection Mapper", - + // "item.edit.item-mapper.buttons.add": "Map item to selected collections", "item.edit.item-mapper.buttons.add": "Piesaistīt materiālu piesaistītajai kolekcijai", - + // "item.edit.item-mapper.buttons.remove": "Remove item's mapping for selected collections", "item.edit.item-mapper.buttons.remove": "Dzēst materiālus izvēlētajai kolekcijai", - + // "item.edit.item-mapper.cancel": "Cancel", "item.edit.item-mapper.cancel": "Atcelt", - + // "item.edit.item-mapper.description": "This is the item mapper tool that allows administrators to map this item to other collections. You can search for collections and map them, or browse the list of collections the item is currently mapped to.", "item.edit.item-mapper.description": "Šis ir materiālu piesaistīšanas rīks, kas ļauj administratoriem piesaistīt šo materiālu citās kolekcijās. Jūs varat meklēt kolekcijas un piesaistīt tās vai pārlūkot kolekciju sarakstu, uz kuru materiāls pašlaik ir piesaistīts.", - + // "item.edit.item-mapper.head": "Item Mapper - Map Item to Collections", "item.edit.item-mapper.head": "Materiāla piesaistīšana - Piesaistīt Materiālu Kolekcijai", - + // "item.edit.item-mapper.item": "Item: \"{{name}}\"", "item.edit.item-mapper.item": "Materiāls: \"{{name}}\"", - + // "item.edit.item-mapper.no-search": "Please enter a query to search", "item.edit.item-mapper.no-search": "Lūdzu ievadiet vaicājumu, lai meklētu", - + // "item.edit.item-mapper.notifications.add.error.content": "Errors occurred for mapping of item to {{amount}} collections.", "item.edit.item-mapper.notifications.add.error.content": "Radās kļūda piesaistot materiālus {{amount}} kolekcijai.", - + // "item.edit.item-mapper.notifications.add.error.head": "Mapping errors", "item.edit.item-mapper.notifications.add.error.head": "Piesaistīšanas kļūdas", - + // "item.edit.item-mapper.notifications.add.success.content": "Successfully mapped item to {{amount}} collections.", "item.edit.item-mapper.notifications.add.success.content": "Veiksmīgi piesaistīto materiāli {{amount}} kolekcijai.", - + // "item.edit.item-mapper.notifications.add.success.head": "Mapping completed", "item.edit.item-mapper.notifications.add.success.head": "Piesaistīšana pabeigta", - + // "item.edit.item-mapper.notifications.remove.error.content": "Errors occurred for the removal of the mapping to {{amount}} collections.", "item.edit.item-mapper.notifications.remove.error.content": "Rādās kļūdas noņemot piesaistes {{amount}} kolekcijām.", - + // "item.edit.item-mapper.notifications.remove.error.head": "Removal of mapping errors", "item.edit.item-mapper.notifications.remove.error.head": "Piesaistīšanas kļūdu noņemšana", - + // "item.edit.item-mapper.notifications.remove.success.content": "Successfully removed mapping of item to {{amount}} collections.", "item.edit.item-mapper.notifications.remove.success.content": "Veiksmīgi noņemta materiālu piesaiste {{amount}} kolekcijās.", - + // "item.edit.item-mapper.notifications.remove.success.head": "Removal of mapping completed", "item.edit.item-mapper.notifications.remove.success.head": "Piesaistes noņemšana ir pabeigta", - + // "item.edit.item-mapper.tabs.browse": "Browse mapped collections", "item.edit.item-mapper.tabs.browse": "Pārlūkot piesaistīās kolekcijas", - + // "item.edit.item-mapper.tabs.map": "Map new collections", "item.edit.item-mapper.tabs.map": "Piesaistīt jaunu kolekciju", - - - + + + // "item.edit.metadata.add-button": "Add", "item.edit.metadata.add-button": "Pievienot", - + // "item.edit.metadata.discard-button": "Discard", "item.edit.metadata.discard-button": "Atmest", - + // "item.edit.metadata.edit.buttons.edit": "Edit", "item.edit.metadata.edit.buttons.edit": "Rediģēt", - + // "item.edit.metadata.edit.buttons.remove": "Remove", "item.edit.metadata.edit.buttons.remove": "Dzēst", - + // "item.edit.metadata.edit.buttons.undo": "Undo changes", "item.edit.metadata.edit.buttons.undo": "Atsaukt izmaiņas", - + // "item.edit.metadata.edit.buttons.unedit": "Stop editing", "item.edit.metadata.edit.buttons.unedit": "Pārtraukt rediģēšanu", - + // "item.edit.metadata.empty": "The item currently doesn't contain any metadata. Click Add to start adding a metadata value.", // TODO New key - Add a translation "item.edit.metadata.empty": "The item currently doesn't contain any metadata. Click Add to start adding a metadata value.", - + // "item.edit.metadata.headers.edit": "Edit", "item.edit.metadata.headers.edit": "Rediģēt", - + // "item.edit.metadata.headers.field": "Field", "item.edit.metadata.headers.field": "Lauks", - + // "item.edit.metadata.headers.language": "Lang", "item.edit.metadata.headers.language": "Valoda", - + // "item.edit.metadata.headers.value": "Value", "item.edit.metadata.headers.value": "Vērtība", - + // "item.edit.metadata.metadatafield.invalid": "Please choose a valid metadata field", "item.edit.metadata.metadatafield.invalid": "Lūdzu, izvēlieties derīgu metadatu lauku", - + // "item.edit.metadata.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", "item.edit.metadata.notifications.discarded.content": "Jūsu veiktās izmaiņas tika atmestas. Lai atjaunotu izmaiņas, noklikšķiniet uz pogas 'Atsaukt'", - + // "item.edit.metadata.notifications.discarded.title": "Changed discarded", "item.edit.metadata.notifications.discarded.title": "Mainīts atmests", - + // "item.edit.metadata.notifications.error.title": "An error occurred", // TODO New key - Add a translation "item.edit.metadata.notifications.error.title": "An error occurred", - + // "item.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", "item.edit.metadata.notifications.invalid.content": "Jūsu veiktās izmaiņas netika saglabātas. Pirms saglabāšanas, lūdzu, pārliecinieties, vai visi lauki ir derīgi.", - + // "item.edit.metadata.notifications.invalid.title": "Metadata invalid", "item.edit.metadata.notifications.invalid.title": "Metadati nav derīgi", - + // "item.edit.metadata.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", "item.edit.metadata.notifications.outdated.content": "Materiāls, kurā pašlaik strādājat, ir mainījis cits lietotājs. Jūsu pašreizējās izmaiņas tiek atmestas, lai novērstu konfliktus", - + // "item.edit.metadata.notifications.outdated.title": "Changed outdated", "item.edit.metadata.notifications.outdated.title": "Mainīts novecojis", - + // "item.edit.metadata.notifications.saved.content": "Your changes to this item's metadata were saved.", "item.edit.metadata.notifications.saved.content": "Jūsu veiktās izmaiņas šī materiāla metadatos tika saglabātas.", - + // "item.edit.metadata.notifications.saved.title": "Metadata saved", "item.edit.metadata.notifications.saved.title": "Metadati saglabāti", - + // "item.edit.metadata.reinstate-button": "Undo", "item.edit.metadata.reinstate-button": "Atsaukt", - + // "item.edit.metadata.save-button": "Save", "item.edit.metadata.save-button": "Saglabāt", - - - + + + // "item.edit.modify.overview.field": "Field", "item.edit.modify.overview.field": "Lauks", - + // "item.edit.modify.overview.language": "Language", "item.edit.modify.overview.language": "Valoda", - + // "item.edit.modify.overview.value": "Value", "item.edit.modify.overview.value": "Vērtība", - - - + + + // "item.edit.move.cancel": "Cancel", "item.edit.move.cancel": "Atcelt", - + // "item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", "item.edit.move.description": "Atlasiet kolekciju, uz kuru vēlaties pārvietot šo materiālu. Lai sašaurinātu parādīto kolekciju sarakstu, lodziņā varat ievadīt meklēšanas vaicājumu.", - + // "item.edit.move.error": "An error occurred when attempting to move the item", "item.edit.move.error": "Mēģinot pārvietot materiālu, radās kļūda", - + // "item.edit.move.head": "Move item: {{id}}", "item.edit.move.head": "Pārvietot materiālu: {{id}}", - + // "item.edit.move.inheritpolicies.checkbox": "Inherit policies", "item.edit.move.inheritpolicies.checkbox": "Mantot nosacījumus", - + // "item.edit.move.inheritpolicies.description": "Inherit the default policies of the destination collection", "item.edit.move.inheritpolicies.description": "Mantot mērķa kolekcijas noklusējuma politikas", - + // "item.edit.move.move": "Move", "item.edit.move.move": "Pārvietot", - + // "item.edit.move.processing": "Moving...", "item.edit.move.processing": "Pārvieto...", - + // "item.edit.move.search.placeholder": "Enter a search query to look for collections", "item.edit.move.search.placeholder": "Ievadiet meklēšanas vaicājumu, lai meklētu kolekcijas", - + // "item.edit.move.success": "The item has been moved successfully", "item.edit.move.success": "Materiāls ir veiksmīgi pārvietots", - + // "item.edit.move.title": "Move item", "item.edit.move.title": "Pārvietot materiālu", - - - + + + // "item.edit.private.cancel": "Cancel", "item.edit.private.cancel": "Atcelt", - + // "item.edit.private.confirm": "Make it Private", "item.edit.private.confirm": "Padarīt privātu", - + // "item.edit.private.description": "Are you sure this item should be made private in the archive?", "item.edit.private.description": "Vai esat pārliecināts, ka šim materiālam arhīvā jābūt privātam?", - + // "item.edit.private.error": "An error occurred while making the item private", "item.edit.private.error": "Padarot elementu privātu radās kļūda", - + // "item.edit.private.header": "Make item private: {{ id }}", "item.edit.private.header": "Padarīt materiālu privātu: {{ id }}", - + // "item.edit.private.success": "The item is now private", "item.edit.private.success": "Materiāls tagad ir privāts", - - - + + + // "item.edit.public.cancel": "Cancel", "item.edit.public.cancel": "Atcelt", - + // "item.edit.public.confirm": "Make it Public", "item.edit.public.confirm": "Padarīt Publisku", - + // "item.edit.public.description": "Are you sure this item should be made public in the archive?", "item.edit.public.description": "Vai tiešām vēlaties šo vienumu publiskot arhīvā?", - + // "item.edit.public.error": "An error occurred while making the item public", "item.edit.public.error": "Padarot elementu publisku radās kļūda", - + // "item.edit.public.header": "Make item public: {{ id }}", "item.edit.public.header": "Padarīt materiālu publisku: {{ id }}", - + // "item.edit.public.success": "The item is now public", "item.edit.public.success": "Materiāls tagad ir publisks", - - - + + + // "item.edit.reinstate.cancel": "Cancel", "item.edit.reinstate.cancel": "Atcelt", - + // "item.edit.reinstate.confirm": "Reinstate", "item.edit.reinstate.confirm": "Atjaunot", - + // "item.edit.reinstate.description": "Are you sure this item should be reinstated to the archive?", "item.edit.reinstate.description": "Vai esat pārliecināts, ka šo materiālu nepieciešams atjaunot arhīvā?", - + // "item.edit.reinstate.error": "An error occurred while reinstating the item", "item.edit.reinstate.error": "Atjaunot materiālu, radās kļūda", - + // "item.edit.reinstate.header": "Reinstate item: {{ id }}", "item.edit.reinstate.header": "Atjaunot materiālu: {{ id }}", - + // "item.edit.reinstate.success": "The item was reinstated successfully", "item.edit.reinstate.success": "Materiāls tika veiksmīgi atjaunots", - - - + + + // "item.edit.relationships.discard-button": "Discard", "item.edit.relationships.discard-button": "Atcelt", - + // "item.edit.relationships.edit.buttons.add": "Add", // TODO New key - Add a translation "item.edit.relationships.edit.buttons.add": "Add", - + // "item.edit.relationships.edit.buttons.remove": "Remove", "item.edit.relationships.edit.buttons.remove": "Dzēst", - + // "item.edit.relationships.edit.buttons.undo": "Undo changes", "item.edit.relationships.edit.buttons.undo": "Atsaukt izmaiņas", - + // "item.edit.relationships.no-relationships": "No relationships", // TODO New key - Add a translation "item.edit.relationships.no-relationships": "No relationships", - + // "item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", "item.edit.relationships.notifications.discarded.content": "Jūsu veiktās izmaiņas tika atmestas. Lai atjaunotu izmaiņas, noklikšķiniet uz pogas 'Atsaukt'", - + // "item.edit.relationships.notifications.discarded.title": "Changes discarded", "item.edit.relationships.notifications.discarded.title": "Mainīts atmests", - + // "item.edit.relationships.notifications.failed.title": "Error editing relationships", // TODO Source message changed - Revise the translation "item.edit.relationships.notifications.failed.title": "Kļūda dzēšot saikni", - + // "item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", "item.edit.relationships.notifications.outdated.content": "Materiāls, kurā pašlaik strādājat, ir mainījis cits lietotājs. Jūsu pašreizējās izmaiņas tiek atmestas, lai novērstu konfliktus", - + // "item.edit.relationships.notifications.outdated.title": "Changes outdated", "item.edit.relationships.notifications.outdated.title": "Novecojušas izmaiņas", - + // "item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.", "item.edit.relationships.notifications.saved.content": "Jūsu veiktās izmaiņas šī materiāla saiknēs tika saglabātas.", - + // "item.edit.relationships.notifications.saved.title": "Relationships saved", "item.edit.relationships.notifications.saved.title": "Saiknes saglabātas", - + // "item.edit.relationships.reinstate-button": "Undo", "item.edit.relationships.reinstate-button": "Atsaukt", - + // "item.edit.relationships.save-button": "Save", "item.edit.relationships.save-button": "Saglabāt", - + // "item.edit.relationships.no-entity-type": "Add 'dspace.entity.type' metadata to enable relationships for this item", // TODO New key - Add a translation "item.edit.relationships.no-entity-type": "Add 'dspace.entity.type' metadata to enable relationships for this item", - - - + + + // "item.edit.tabs.bitstreams.head": "Bitstreams", "item.edit.tabs.bitstreams.head": "Bitu straume", - + // "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", "item.edit.tabs.bitstreams.title": "Rediģēt Materiālu - Bitu straumes", - + // "item.edit.tabs.curate.head": "Curate", "item.edit.tabs.curate.head": "Pārvaldīt", - + // "item.edit.tabs.curate.title": "Item Edit - Curate", "item.edit.tabs.curate.title": "Rediģēt Materiālu - Pārvaldība", - + // "item.edit.tabs.metadata.head": "Metadata", "item.edit.tabs.metadata.head": "Metadati", - + // "item.edit.tabs.metadata.title": "Item Edit - Metadata", "item.edit.tabs.metadata.title": "Rediģēt Materiālu - Metadati", - + // "item.edit.tabs.relationships.head": "Relationships", "item.edit.tabs.relationships.head": "Attiecības", - + // "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.relationships.title": "Rediģēt Materiālu - Attiecības", - + // "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", "item.edit.tabs.status.buttons.authorizations.button": "Tiesības...", - + // "item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies", "item.edit.tabs.status.buttons.authorizations.label": "Rediģēt materiāla tiesības", - + // "item.edit.tabs.status.buttons.delete.button": "Permanently delete", "item.edit.tabs.status.buttons.delete.button": "Neatgriezeniski izdzēst", - + // "item.edit.tabs.status.buttons.delete.label": "Completely expunge item", "item.edit.tabs.status.buttons.delete.label": "Pilnīgi izņemt materiālu", - + // "item.edit.tabs.status.buttons.mappedCollections.button": "Mapped collections", "item.edit.tabs.status.buttons.mappedCollections.button": "Piesaistītās kolekcijas", - + // "item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections", "item.edit.tabs.status.buttons.mappedCollections.label": "Pārvaldīt piesaistītās kolekcijas", - + // "item.edit.tabs.status.buttons.move.button": "Move...", "item.edit.tabs.status.buttons.move.button": "Pārvieto...", - + // "item.edit.tabs.status.buttons.move.label": "Move item to another collection", "item.edit.tabs.status.buttons.move.label": "Pārvietot materiālu uz citu kolekciju", - + // "item.edit.tabs.status.buttons.private.button": "Make it private...", "item.edit.tabs.status.buttons.private.button": "Padarīt to privātu...", - + // "item.edit.tabs.status.buttons.private.label": "Make item private", "item.edit.tabs.status.buttons.private.label": "Padarīt materiālu privātu", - + // "item.edit.tabs.status.buttons.public.button": "Make it public...", "item.edit.tabs.status.buttons.public.button": "Padarīt to publisku...", - + // "item.edit.tabs.status.buttons.public.label": "Make item public", "item.edit.tabs.status.buttons.public.label": "Padarīt materiālu publisku", - + // "item.edit.tabs.status.buttons.reinstate.button": "Reinstate...", "item.edit.tabs.status.buttons.reinstate.button": "Atjaunot...", - + // "item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository", "item.edit.tabs.status.buttons.reinstate.label": "Atjaunot materiālu repozitorijā", - + // "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...", "item.edit.tabs.status.buttons.withdraw.button": "Atsaukt...", - + // "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository", "item.edit.tabs.status.buttons.withdraw.label": "Izņemiet materiālu no repozitorijas", - + // "item.edit.tabs.status.description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.", "item.edit.tabs.status.description": "Laipni lūdzam vienumu pārvaldības lapā. Šeit jūs varat materiālu izņemt, atjaunot, pārvietot vai izdzēst. Citās cilnēs varat arī atjaunināt vai pievienot jaunus metadatus / bitu straumes.", - + // "item.edit.tabs.status.head": "Status", "item.edit.tabs.status.head": "Status", - + // "item.edit.tabs.status.labels.handle": "Handle", "item.edit.tabs.status.labels.handle": "Apstrāde", - + // "item.edit.tabs.status.labels.id": "Item Internal ID", "item.edit.tabs.status.labels.id": "Materiāla Iekšējais ID", - + // "item.edit.tabs.status.labels.itemPage": "Item Page", "item.edit.tabs.status.labels.itemPage": "Materiāla Lapa", - + // "item.edit.tabs.status.labels.lastModified": "Last Modified", "item.edit.tabs.status.labels.lastModified": "Pēdējo reizi modificēts", - + // "item.edit.tabs.status.title": "Item Edit - Status", "item.edit.tabs.status.title": "Rediģēt Materiālu - Statuss", - + // "item.edit.tabs.versionhistory.head": "Version History", "item.edit.tabs.versionhistory.head": "Versiju Vēsture", - + // "item.edit.tabs.versionhistory.title": "Item Edit - Version History", "item.edit.tabs.versionhistory.title": "Rediģēt Materiālu - Versiju Vēsture", - + // "item.edit.tabs.versionhistory.under-construction": "Editing or adding new versions is not yet possible in this user interface.", "item.edit.tabs.versionhistory.under-construction": "Šajā lietotāja saskarnē vēl nav iespējams ienākt vai pievienot jaunas versijas.", - + // "item.edit.tabs.view.head": "View Item", "item.edit.tabs.view.head": "Skatīt Materiālu", - + // "item.edit.tabs.view.title": "Item Edit - View", "item.edit.tabs.view.title": "Materiāla Rediģēšana - Skats", - - - + + + // "item.edit.withdraw.cancel": "Cancel", "item.edit.withdraw.cancel": "Atcelt", - + // "item.edit.withdraw.confirm": "Withdraw", "item.edit.withdraw.confirm": "Atsaukt", - + // "item.edit.withdraw.description": "Are you sure this item should be withdrawn from the archive?", "item.edit.withdraw.description": "Vai esat pārliecināts, ka šo materiālu vajadzētu izņemt no arhīva?", - + // "item.edit.withdraw.error": "An error occurred while withdrawing the item", "item.edit.withdraw.error": "Izņemot materiālu, radās kļūda", - + // "item.edit.withdraw.header": "Withdraw item: {{ id }}", "item.edit.withdraw.header": "Izņemt materiālu: {{ id }}", - + // "item.edit.withdraw.success": "The item was withdrawn successfully", "item.edit.withdraw.success": "Materiāls tika veiksmīgi izņemts", - - - + + + // "item.listelement.badge": "Item", // TODO New key - Add a translation "item.listelement.badge": "Item", - + // "item.page.description": "Description", // TODO New key - Add a translation "item.page.description": "Description", - + // "item.page.edit": "Edit this item", // TODO New key - Add a translation "item.page.edit": "Edit this item", - + // "item.page.journal-issn": "Journal ISSN", // TODO New key - Add a translation "item.page.journal-issn": "Journal ISSN", - + // "item.page.journal-title": "Journal Title", // TODO New key - Add a translation "item.page.journal-title": "Journal Title", - + // "item.page.publisher": "Publisher", // TODO New key - Add a translation "item.page.publisher": "Publisher", - + // "item.page.titleprefix": "Item: ", // TODO New key - Add a translation "item.page.titleprefix": "Item: ", - + // "item.page.volume-title": "Volume Title", // TODO New key - Add a translation "item.page.volume-title": "Volume Title", - + // "item.search.results.head": "Item Search Results", // TODO New key - Add a translation "item.search.results.head": "Item Search Results", - + // "item.search.title": "DSpace Angular :: Item Search", // TODO New key - Add a translation "item.search.title": "DSpace Angular :: Item Search", - - - + + + // "item.page.abstract": "Abstract", "item.page.abstract": "Kopsavilkums", - + // "item.page.author": "Authors", "item.page.author": "Autori", - + // "item.page.citation": "Citation", "item.page.citation": "Citēšana", - + // "item.page.collections": "Collections", "item.page.collections": "Kolekcijas", - + // "item.page.date": "Date", "item.page.date": "Datums", - + // "item.page.edit": "Edit this item", // TODO New key - Add a translation "item.page.edit": "Edit this item", - + // "item.page.files": "Files", "item.page.files": "Faili", - + // "item.page.filesection.description": "Description:", "item.page.filesection.description": "Apraksts:", - + // "item.page.filesection.download": "Download", "item.page.filesection.download": "Lejupielādēt", - + // "item.page.filesection.format": "Format:", "item.page.filesection.format": "Formāts:", - + // "item.page.filesection.name": "Name:", "item.page.filesection.name": "Nosaukums:", - + // "item.page.filesection.size": "Size:", "item.page.filesection.size": "Izmērs:", - + // "item.page.journal.search.title": "Articles in this journal", "item.page.journal.search.title": "Raksti šajā žurnālā", - + // "item.page.link.full": "Full item page", "item.page.link.full": "Pilna materiālu lapa", - + // "item.page.link.simple": "Simple item page", "item.page.link.simple": "Vienkārša materiālu lapa", - + // "item.page.person.search.title": "Articles by this author", "item.page.person.search.title": "Šī autora raksti", - + // "item.page.related-items.view-more": "Show {{ amount }} more", "item.page.related-items.view-more": "Parādīt {{ amount }} vairāk", - + // "item.page.related-items.view-less": "Hide last {{ amount }}", "item.page.related-items.view-less": "Paslēpt pēdējo {{ amount }}", - + // "item.page.relationships.isAuthorOfPublication": "Publications", "item.page.relationships.isAuthorOfPublication": "Publikācijas", - + // "item.page.relationships.isJournalOfPublication": "Publications", "item.page.relationships.isJournalOfPublication": "Publikācijas", - + // "item.page.relationships.isOrgUnitOfPerson": "Authors", "item.page.relationships.isOrgUnitOfPerson": "Autori", - + // "item.page.relationships.isOrgUnitOfProject": "Research Projects", "item.page.relationships.isOrgUnitOfProject": "Pētniecības projekti", - + // "item.page.subject": "Keywords", "item.page.subject": "Atslēgas vārdi", - + // "item.page.uri": "URI", "item.page.uri": "URI", - + // "item.page.bitstreams.view-more": "Show more", // TODO New key - Add a translation "item.page.bitstreams.view-more": "Show more", - + // "item.page.bitstreams.collapse": "Collapse", // TODO New key - Add a translation "item.page.bitstreams.collapse": "Collapse", - + // "item.page.filesection.original.bundle" : "Original bundle", // TODO New key - Add a translation "item.page.filesection.original.bundle" : "Original bundle", - + // "item.page.filesection.license.bundle" : "License bundle", // TODO New key - Add a translation "item.page.filesection.license.bundle" : "License bundle", - + // "item.preview.dc.identifier.uri": "Identifier:", // TODO New key - Add a translation "item.preview.dc.identifier.uri": "Identifier:", - + // "item.preview.dc.contributor.author": "Authors:", // TODO New key - Add a translation "item.preview.dc.contributor.author": "Authors:", - + // "item.preview.dc.date.issued": "Published date:", // TODO New key - Add a translation "item.preview.dc.date.issued": "Published date:", - + // "item.preview.dc.description.abstract": "Abstract:", // TODO New key - Add a translation "item.preview.dc.description.abstract": "Abstract:", - + // "item.preview.dc.identifier.other": "Other identifier:", // TODO New key - Add a translation "item.preview.dc.identifier.other": "Other identifier:", - + // "item.preview.dc.language.iso": "Language:", // TODO New key - Add a translation "item.preview.dc.language.iso": "Language:", - + // "item.preview.dc.subject": "Subjects:", // TODO New key - Add a translation "item.preview.dc.subject": "Subjects:", - + // "item.preview.dc.title": "Title:", // TODO New key - Add a translation "item.preview.dc.title": "Title:", - + // "item.preview.person.familyName": "Surname:", // TODO New key - Add a translation "item.preview.person.familyName": "Surname:", - + // "item.preview.person.givenName": "Name:", // TODO New key - Add a translation "item.preview.person.givenName": "Name:", - + // "item.preview.person.identifier.orcid": "ORCID:", // TODO New key - Add a translation "item.preview.person.identifier.orcid": "ORCID:", - - + + // "item.select.confirm": "Confirm selected", "item.select.confirm": "Apstiprināt izvēlētos", - + // "item.select.empty": "No items to show", "item.select.empty": "Ieraksti nav atrasti", - + // "item.select.table.author": "Author", "item.select.table.author": "Autors", - + // "item.select.table.collection": "Collection", "item.select.table.collection": "Kolekcija", - + // "item.select.table.title": "Title", "item.select.table.title": "Nosaukums", - - + + // "item.version.history.empty": "There are no other versions for this item yet.", "item.version.history.empty": "Šim materiālam vēl nav citu versiju.", - + // "item.version.history.head": "Version History", "item.version.history.head": "Versiju Vēsture", - + // "item.version.history.return": "Return", "item.version.history.return": "Atgriezties", - + // "item.version.history.selected": "Selected version", "item.version.history.selected": "Izvēlētā versija", - + // "item.version.history.table.version": "Version", "item.version.history.table.version": "Versija", - + // "item.version.history.table.item": "Item", "item.version.history.table.item": "Materiāls", - + // "item.version.history.table.editor": "Editor", "item.version.history.table.editor": "Redaktors", - + // "item.version.history.table.date": "Date", "item.version.history.table.date": "Datums", - + // "item.version.history.table.summary": "Summary", "item.version.history.table.summary": "Kopsavilkums", - - - + + + // "item.version.notice": "This is not the latest version of this item. The latest version can be found here.", "item.version.notice": "Šī nav jaunākā šī materiāla versija. Jaunāko versiju var atrast here.", - - - + + + // "journal.listelement.badge": "Journal", "journal.listelement.badge": "Žurnāls", - + // "journal.page.description": "Description", "journal.page.description": "Apraksts", - + // "journal.page.edit": "Edit this item", // TODO New key - Add a translation "journal.page.edit": "Edit this item", - + // "journal.page.editor": "Editor-in-Chief", "journal.page.editor": "Galvenais Radaktors", - + // "journal.page.issn": "ISSN", "journal.page.issn": "ISSN", - + // "journal.page.publisher": "Publisher", "journal.page.publisher": "Izdevējs", - + // "journal.page.titleprefix": "Journal: ", "journal.page.titleprefix": "Žurnāls: ", - + // "journal.search.results.head": "Journal Search Results", "journal.search.results.head": "Žurnālu meklēšanas rezultāti", - + // "journal.search.title": "DSpace Angular :: Journal Search", "journal.search.title": "DSpace Angular :: Žurnālu Meklēšana", - - - + + + // "journalissue.listelement.badge": "Journal Issue", "journalissue.listelement.badge": "Žurnāla izdevums", - + // "journalissue.page.description": "Description", "journalissue.page.description": "Apraksts", - + // "journalissue.page.edit": "Edit this item", // TODO New key - Add a translation "journalissue.page.edit": "Edit this item", - + // "journalissue.page.issuedate": "Issue Date", "journalissue.page.issuedate": "Izdošanas Datums", - + // "journalissue.page.journal-issn": "Journal ISSN", "journalissue.page.journal-issn": "Žurnāla ISSN", - + // "journalissue.page.journal-title": "Journal Title", "journalissue.page.journal-title": "Žurnāla nosaukums", - + // "journalissue.page.keyword": "Keywords", "journalissue.page.keyword": "Atslēgas vārdi", - + // "journalissue.page.number": "Number", "journalissue.page.number": "Numurs", - + // "journalissue.page.titleprefix": "Journal Issue: ", "journalissue.page.titleprefix": "Žurnāla izdevums: ", - - - + + + // "journalvolume.listelement.badge": "Journal Volume", "journalvolume.listelement.badge": "Žurnāla sējums", - + // "journalvolume.page.description": "Description", "journalvolume.page.description": "Apraksts", - + // "journalvolume.page.edit": "Edit this item", // TODO New key - Add a translation "journalvolume.page.edit": "Edit this item", - + // "journalvolume.page.issuedate": "Issue Date", "journalvolume.page.issuedate": "Izdošanas Datums", - + // "journalvolume.page.titleprefix": "Journal Volume: ", "journalvolume.page.titleprefix": "Žurnāla sējums: ", - + // "journalvolume.page.volume": "Volume", "journalvolume.page.volume": "Sējums", - - - + + + // "loading.bitstream": "Loading bitstream...", "loading.bitstream": "Notiek bitu straumes ielāde...", - + // "loading.bitstreams": "Loading bitstreams...", "loading.bitstreams": "Notiek bitu straumes ielāde...", - + // "loading.browse-by": "Loading items...", "loading.browse-by": "Notiek materiālu ielāde...", - + // "loading.browse-by-page": "Loading page...", "loading.browse-by-page": "Notiek lapas ielāde...", - + // "loading.collection": "Loading collection...", "loading.collection": "Notiek kolekcijas ielāde...", - + // "loading.collections": "Loading collections...", "loading.collections": "Notiek kolekciju ielāde...", - + // "loading.content-source": "Loading content source...", "loading.content-source": "Notiek satura avota ielāde...", - + // "loading.community": "Loading community...", "loading.community": "Notiek kategoriju ielāde...", - + // "loading.default": "Loading...", "loading.default": "Notiek ielāde...", - + // "loading.item": "Loading item...", "loading.item": "Notiek materiāla ielāde...", - + // "loading.items": "Loading items...", "loading.items": "Notiek materiālu ielāde...", - + // "loading.mydspace-results": "Loading items...", "loading.mydspace-results": "Notiek materiālu ielāde..", - + // "loading.objects": "Loading...", "loading.objects": "Notiek ielāde...", - + // "loading.recent-submissions": "Loading recent submissions...", "loading.recent-submissions": "Notiek neseno iesniegumu ielāde...", - + // "loading.search-results": "Loading search results...", "loading.search-results": "Notiek maklēšanas rezulātu ielāde...", - + // "loading.sub-collections": "Loading sub-collections...", "loading.sub-collections": "Notiek apakškolekciju ielāde...", - + // "loading.sub-communities": "Loading sub-communities...", "loading.sub-communities": "Notiek apakškategoriju ielāde...", - + // "loading.top-level-communities": "Loading top-level communities...", "loading.top-level-communities": "Notiek augstākā līmeņa kategorju ielāde...", - - - + + + // "login.form.email": "Email address", "login.form.email": "E-pasta adrese", - + // "login.form.forgot-password": "Have you forgotten your password?", "login.form.forgot-password": "Vai esat aizmirsis paroli?", - + // "login.form.header": "Please log in to DSpace", "login.form.header": "Lūdzu pieslēdzieties DSpace", - + // "login.form.new-user": "New user? Click here to register.", "login.form.new-user": "Jauns lietotājs? Noklikšķiniet šeit, lai reģistrētos.", - + // "login.form.or-divider": "or", "login.form.or-divider": "vai", - + // "login.form.password": "Password", "login.form.password": "Parole", - + // "login.form.shibboleth": "Log in with Shibboleth", "login.form.shibboleth": "Pieslēgties ar Shibboleth", - + // "login.form.submit": "Log in", "login.form.submit": "Pieslēgties", - + // "login.title": "Login", "login.title": "Pierakstīties", - + // "login.breadcrumbs": "Login", "login.breadcrumbs": "Pierakstīties", - - - + + + // "logout.form.header": "Log out from DSpace", "logout.form.header": "Izrakstīties no DSpace", - + // "logout.form.submit": "Log out", "logout.form.submit": "Izrakstīties", - + // "logout.title": "Logout", "logout.title": "Izrakstīties", - - - + + + // "menu.header.admin": "Admin", "menu.header.admin": "Administrators", - + // "menu.header.image.logo": "Repository logo", "menu.header.image.logo": "Repozitorijas logotips", - - - + + + // "menu.section.access_control": "Access Control", "menu.section.access_control": "Piekļuves kontrole", - + // "menu.section.access_control_authorizations": "Authorizations", "menu.section.access_control_authorizations": "Pilnvaras", - + // "menu.section.access_control_groups": "Groups", "menu.section.access_control_groups": "Grupas", - + // "menu.section.access_control_people": "People", "menu.section.access_control_people": "Personas", - - - + + + // "menu.section.admin_search": "Admin Search", "menu.section.admin_search": "Administratora Meklēšana", - - - + + + // "menu.section.browse_community": "This Community", "menu.section.browse_community": "Šī kategorija", - + // "menu.section.browse_community_by_author": "By Author", "menu.section.browse_community_by_author": "Pēc Autora", - + // "menu.section.browse_community_by_issue_date": "By Issue Date", "menu.section.browse_community_by_issue_date": "Pēc Izdošanas Datuma", - + // "menu.section.browse_community_by_title": "By Title", "menu.section.browse_community_by_title": "Pēc Nosaukuma", - + // "menu.section.browse_global": "All of DSpace", "menu.section.browse_global": "Viss no DSpace", - + // "menu.section.browse_global_by_author": "By Author", "menu.section.browse_global_by_author": "Pēc Autora", - + // "menu.section.browse_global_by_dateissued": "By Issue Date", "menu.section.browse_global_by_dateissued": "Pēc Izdošanas Datuma", - + // "menu.section.browse_global_by_subject": "By Subject", "menu.section.browse_global_by_subject": "Pēc Priekšmeta", - + // "menu.section.browse_global_by_title": "By Title", "menu.section.browse_global_by_title": "Pēc Nosaukuma", - + // "menu.section.browse_global_communities_and_collections": "Communities & Collections", "menu.section.browse_global_communities_and_collections": "Kategorijas & Kolekcijas", - - - + + + // "menu.section.control_panel": "Control Panel", "menu.section.control_panel": "Vadības Panelis", - + // "menu.section.curation_task": "Curation Task", "menu.section.curation_task": "Kuratora Uzdevums", - - - + + + // "menu.section.edit": "Edit", "menu.section.edit": "Rediģēt", - + // "menu.section.edit_collection": "Collection", "menu.section.edit_collection": "Kolekcija", - + // "menu.section.edit_community": "Community", "menu.section.edit_community": "Kategorija", - + // "menu.section.edit_item": "Item", "menu.section.edit_item": "Materiāls", - - - + + + // "menu.section.export": "Export", "menu.section.export": "Eksportēt", - + // "menu.section.export_collection": "Collection", "menu.section.export_collection": "Kolekcija", - + // "menu.section.export_community": "Community", "menu.section.export_community": "Kategorija", - + // "menu.section.export_item": "Item", "menu.section.export_item": "Materiāls", - + // "menu.section.export_metadata": "Metadata", "menu.section.export_metadata": "Metadati", - - - + + + // "menu.section.icon.access_control": "Access Control menu section", "menu.section.icon.access_control": "Piekļuves kontroles izvēlnes sadaļa", - + // "menu.section.icon.admin_search": "Admin search menu section", "menu.section.icon.admin_search": "Administratoru meklēšanas izvēlnes sadaļa", - + // "menu.section.icon.control_panel": "Control Panel menu section", "menu.section.icon.control_panel": "Vadības paneļa izvēlnes sadaļa", - + // "menu.section.icon.curation_task": "Curation Task menu section", "menu.section.icon.curation_task": "Kuratora uzdevumu izvēlnes sadaļa", - + // "menu.section.icon.edit": "Edit menu section", "menu.section.icon.edit": "Labot izvēlnes sadaļu", - + // "menu.section.icon.export": "Export menu section", "menu.section.icon.export": "Eksportēt izvēlnes sadaļu", - + // "menu.section.icon.find": "Find menu section", "menu.section.icon.find": "Atrast izvēlnes sadaļu", - + // "menu.section.icon.import": "Import menu section", "menu.section.icon.import": "Importēt izvēlnes sadaļa", - + // "menu.section.icon.new": "New menu section", "menu.section.icon.new": "Jauna izvēlnes sadaļa", - + // "menu.section.icon.pin": "Pin sidebar", "menu.section.icon.pin": "Piespraust sānjoslu", - + // "menu.section.icon.processes": "Processes menu section", // TODO New key - Add a translation "menu.section.icon.processes": "Processes menu section", - + // "menu.section.icon.registries": "Registries menu section", "menu.section.icon.registries": "Reģistru izvēlnes sadaļa", - + // "menu.section.icon.statistics_task": "Statistics Task menu section", "menu.section.icon.statistics_task": "Statistkas uzdevumu izvēlnes sadaļa", - + // "menu.section.icon.unpin": "Unpin sidebar", "menu.section.icon.unpin": "Atspraust sānjoslu", - - - + + + // "menu.section.import": "Import", "menu.section.import": "Importēt", - + // "menu.section.import_batch": "Batch Import (ZIP)", "menu.section.import_batch": "Importēt (ZIP)", - + // "menu.section.import_metadata": "Metadata", "menu.section.import_metadata": "Metadati", - - - + + + // "menu.section.new": "New", "menu.section.new": "Jauns", - + // "menu.section.new_collection": "Collection", "menu.section.new_collection": "Kolekcija", - + // "menu.section.new_community": "Community", "menu.section.new_community": "Kategorija", - + // "menu.section.new_item": "Item", "menu.section.new_item": "Materiāls", - + // "menu.section.new_item_version": "Item Version", "menu.section.new_item_version": "Materiāla Versija", - + // "menu.section.new_process": "Process", // TODO New key - Add a translation "menu.section.new_process": "Process", - - - + + + // "menu.section.pin": "Pin sidebar", "menu.section.pin": "Piespraust sānjoslu", - + // "menu.section.unpin": "Unpin sidebar", "menu.section.unpin": "Atspraust sānjoslu", - - - + + + // "menu.section.processes": "Processes", "menu.section.processes": "Procesi", - - - + + + // "menu.section.registries": "Registries", "menu.section.registries": "Reģistri", - + // "menu.section.registries_format": "Format", "menu.section.registries_format": "Formāts", - + // "menu.section.registries_metadata": "Metadata", "menu.section.registries_metadata": "Metadati", - - - + + + // "menu.section.statistics": "Statistics", "menu.section.statistics": "Statistika", - + // "menu.section.statistics_task": "Statistics Task", "menu.section.statistics_task": "Statistikas Uzdevumi", - - - + + + // "menu.section.toggle.access_control": "Toggle Access Control section", "menu.section.toggle.access_control": "Pārslēgt Piekļuvas Kontronles sadaļu", - + // "menu.section.toggle.control_panel": "Toggle Control Panel section", "menu.section.toggle.control_panel": "Pārslēgt Vadības Paneļa sadaļu", - + // "menu.section.toggle.curation_task": "Toggle Curation Task section", "menu.section.toggle.curation_task": "Pārslēgt Kuratora Uzdevumu sadaļu", - + // "menu.section.toggle.edit": "Toggle Edit section", "menu.section.toggle.edit": "Pārslēgt Rediģēt sadaļu", - + // "menu.section.toggle.export": "Toggle Export section", "menu.section.toggle.export": "Pārslēgt Eksportēt sadaļu", - + // "menu.section.toggle.find": "Toggle Find section", "menu.section.toggle.find": "Pārslēgt Meklēt sadaļu", - + // "menu.section.toggle.import": "Toggle Import section", "menu.section.toggle.import": "Pārslēgt Importēt sadaļu", - + // "menu.section.toggle.new": "Toggle New section", "menu.section.toggle.new": "Pārslēgt Jauns sadaļu", - + // "menu.section.toggle.registries": "Toggle Registries section", "menu.section.toggle.registries": "Pārslēgt Reģistru sadaļu", - + // "menu.section.toggle.statistics_task": "Toggle Statistics Task section", "menu.section.toggle.statistics_task": "Pārslēgt Statistikas Uzdevumu sadaļu", - - + + // "menu.section.workflow": "Administer Workflow", // TODO Source message changed - Revise the translation "menu.section.workflow": "Administrēt darba plūsmu", - - + + // "mydspace.description": "", "mydspace.description": "", - + // "mydspace.general.text-here": "here", // TODO Source message changed - Revise the translation "mydspace.general.text-here": "ŠEIT", - + // "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.", "mydspace.messages.controller-help": "Atlasiet šo opciju, lai nosūtītu materiāla objekta iesniedzējam.", - + // "mydspace.messages.description-placeholder": "Insert your message here...", "mydspace.messages.description-placeholder": "Ievietojiet savu ziņojumu šeit ...", - + // "mydspace.messages.hide-msg": "Hide message", "mydspace.messages.hide-msg": "Paslēpt ziņojumu", - + // "mydspace.messages.mark-as-read": "Mark as read", "mydspace.messages.mark-as-read": "Atzīmēt kā lasītu", - + // "mydspace.messages.mark-as-unread": "Mark as unread", "mydspace.messages.mark-as-unread": "Atzīmēt kā nelasītu", - + // "mydspace.messages.no-content": "No content.", "mydspace.messages.no-content": "Nav satura.", - + // "mydspace.messages.no-messages": "No messages yet.", "mydspace.messages.no-messages": "Nav paziņojumu.", - + // "mydspace.messages.send-btn": "Send", "mydspace.messages.send-btn": "Sūtīt", - + // "mydspace.messages.show-msg": "Show message", "mydspace.messages.show-msg": "Parādīt ziņu", - + // "mydspace.messages.subject-placeholder": "Subject...", "mydspace.messages.subject-placeholder": "Priekšmets...", - + // "mydspace.messages.submitter-help": "Select this option to send a message to controller.", "mydspace.messages.submitter-help": "Atlasiet šo opciju, lai nosūtītu ziņojumu kontrolierim.", - + // "mydspace.messages.title": "Messages", "mydspace.messages.title": "Vēstules", - + // "mydspace.messages.to": "To", "mydspace.messages.to": "Uz", - + // "mydspace.new-submission": "New submission", "mydspace.new-submission": "Jauns iesniegums", - + // "mydspace.new-submission-external": "Import metadata from external source", // TODO New key - Add a translation "mydspace.new-submission-external": "Import metadata from external source", - + // "mydspace.new-submission-external-short": "Import metadata", // TODO New key - Add a translation "mydspace.new-submission-external-short": "Import metadata", - + // "mydspace.results.head": "Your submissions", "mydspace.results.head": "Jūsu iesniegumi", - + // "mydspace.results.no-abstract": "No Abstract", "mydspace.results.no-abstract": "Nav Kopsavilkums", - + // "mydspace.results.no-authors": "No Authors", "mydspace.results.no-authors": "Nav Autoru", - + // "mydspace.results.no-collections": "No Collections", "mydspace.results.no-collections": "Nav Kolekcijas", - + // "mydspace.results.no-date": "No Date", "mydspace.results.no-date": "Nav Datuma", - + // "mydspace.results.no-files": "No Files", "mydspace.results.no-files": "Nav Failu", - + // "mydspace.results.no-results": "There were no items to show", "mydspace.results.no-results": "Nav materiālu, ko parādīt", - + // "mydspace.results.no-title": "No title", "mydspace.results.no-title": "Nav Nosaukuma", - + // "mydspace.results.no-uri": "No Uri", "mydspace.results.no-uri": "Nav Uri", - + // "mydspace.show.workflow": "All tasks", "mydspace.show.workflow": "Visi uzdevumi", - + // "mydspace.show.workspace": "Your Submissions", "mydspace.show.workspace": "Jūsu Iesniegumi", - + // "mydspace.status.archived": "Archived", "mydspace.status.archived": "Arhivēts", - + // "mydspace.status.validation": "Validation", "mydspace.status.validation": "Validācija", - + // "mydspace.status.waiting-for-controller": "Waiting for controller", "mydspace.status.waiting-for-controller": "Gaida kontrolieri", - + // "mydspace.status.workflow": "Workflow", "mydspace.status.workflow": "Darba plūsma", - + // "mydspace.status.workspace": "Workspace", "mydspace.status.workspace": "Darbavieta", - + // "mydspace.title": "MyDSpace", "mydspace.title": "Mans DSpace", - + // "mydspace.upload.upload-failed": "Error creating new workspace. Please verify the content uploaded before retry.", "mydspace.upload.upload-failed": "Veidojot jaunu darbvietu, radās kļūda. Lūdzu pārbaudiet augšupielādēto saturu pirms mēģiniet vēlreiz.", - + // "mydspace.upload.upload-failed-manyentries": "Unprocessable file. Detected too many entries but allowed only one for file.", // TODO New key - Add a translation "mydspace.upload.upload-failed-manyentries": "Unprocessable file. Detected too many entries but allowed only one for file.", - + // "mydspace.upload.upload-failed-moreonefile": "Unprocessable request. Only one file is allowed.", // TODO New key - Add a translation "mydspace.upload.upload-failed-moreonefile": "Unprocessable request. Only one file is allowed.", - + // "mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.", "mydspace.upload.upload-multiple-successful": "{{qty}} izveidoti jauni darbvietas materiāli.", - + // "mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.", "mydspace.upload.upload-successful": "Ir izveidots jauns darbvietas materiāls. Noklikšķiniet {{here}}, lai to rediģētu.", - + // "mydspace.view-btn": "View", "mydspace.view-btn": "Skatīt", - - - + + + // "nav.browse.header": "All of DSpace", "nav.browse.header": "Viss no DSpace", - + // "nav.community-browse.header": "By Community", "nav.community-browse.header": "Pēc Kategorijas", - + // "nav.language": "Language switch", "nav.language": "Valodas maiņa", - + // "nav.login": "Log In", "nav.login": "Pieslēgties", - + // "nav.logout": "Log Out", "nav.logout": "Izrakstīties", - + // "nav.mydspace": "MyDSpace", "nav.mydspace": "Mans DSpace", - + // "nav.profile": "Profile", "nav.profile": "Profils", - + // "nav.search": "Search", "nav.search": "Meklēt", - + // "nav.statistics.header": "Statistics", "nav.statistics.header": "Statistika", - + // "nav.stop-impersonating": "Stop impersonating EPerson", // TODO New key - Add a translation "nav.stop-impersonating": "Stop impersonating EPerson", - - - + + + // "orgunit.listelement.badge": "Organizational Unit", "orgunit.listelement.badge": "Struktūrvienība", - + // "orgunit.page.city": "City", "orgunit.page.city": "Pilsēta", - + // "orgunit.page.country": "Country", "orgunit.page.country": "Valsts", - + // "orgunit.page.dateestablished": "Date established", "orgunit.page.dateestablished": "Dibināšanas datums", - + // "orgunit.page.description": "Description", "orgunit.page.description": "Apraksts", - + // "orgunit.page.edit": "Edit this item", // TODO New key - Add a translation "orgunit.page.edit": "Edit this item", - + // "orgunit.page.id": "ID", "orgunit.page.id": "ID", - + // "orgunit.page.titleprefix": "Organizational Unit: ", "orgunit.page.titleprefix": "Struktūrvienība: ", - - - + + + // "pagination.results-per-page": "Results Per Page", "pagination.results-per-page": "Rezultāti vienā lapā", - + // "pagination.showing.detail": "{{ range }} of {{ total }}", "pagination.showing.detail": "{{ range }} no {{ total }}", - + // "pagination.showing.label": "Now showing ", "pagination.showing.label": "Tagad rāda ", - + // "pagination.sort-direction": "Sort Options", "pagination.sort-direction": "Kārtošanas iespējas", - - - + + + // "person.listelement.badge": "Person", "person.listelement.badge": "Persona", - + // "person.listelement.no-title": "No name found", // TODO New key - Add a translation "person.listelement.no-title": "No name found", - + // "person.page.birthdate": "Birth Date", "person.page.birthdate": "Dzimšanas datums", - + // "person.page.edit": "Edit this item", // TODO New key - Add a translation "person.page.edit": "Edit this item", - + // "person.page.email": "Email Address", "person.page.email": "E-pasta adrese", - + // "person.page.firstname": "First Name", "person.page.firstname": "Vārds", - + // "person.page.jobtitle": "Job Title", "person.page.jobtitle": "Ieņemamais amats", - + // "person.page.lastname": "Last Name", "person.page.lastname": "Uzvārds", - + // "person.page.link.full": "Show all metadata", "person.page.link.full": "Parādīt visus metadatus", - + // "person.page.orcid": "ORCID", "person.page.orcid": "ORCID", - + // "person.page.staffid": "Staff ID", "person.page.staffid": "Personāla ID", - + // "person.page.titleprefix": "Person: ", "person.page.titleprefix": "Persona: ", - + // "person.search.results.head": "Person Search Results", "person.search.results.head": "Personas meklēšanas rezultāti", - + // "person.search.title": "DSpace Angular :: Person Search", "person.search.title": "DSpace Angular :: Personas Meklēšana", - - - + + + // "process.new.select-parameters": "Parameters", // TODO New key - Add a translation "process.new.select-parameters": "Parameters", - + // "process.new.cancel": "Cancel", // TODO New key - Add a translation "process.new.cancel": "Cancel", - + // "process.new.submit": "Submit", // TODO New key - Add a translation "process.new.submit": "Submit", - + // "process.new.select-script": "Script", // TODO New key - Add a translation "process.new.select-script": "Script", - + // "process.new.select-script.placeholder": "Choose a script...", // TODO New key - Add a translation "process.new.select-script.placeholder": "Choose a script...", - + // "process.new.select-script.required": "Script is required", // TODO New key - Add a translation "process.new.select-script.required": "Script is required", - + // "process.new.parameter.file.upload-button": "Select file...", // TODO New key - Add a translation "process.new.parameter.file.upload-button": "Select file...", - + // "process.new.parameter.file.required": "Please select a file", // TODO New key - Add a translation "process.new.parameter.file.required": "Please select a file", - + // "process.new.parameter.string.required": "Parameter value is required", // TODO New key - Add a translation "process.new.parameter.string.required": "Parameter value is required", - + // "process.new.parameter.type.value": "value", // TODO New key - Add a translation "process.new.parameter.type.value": "value", - + // "process.new.parameter.type.file": "file", // TODO New key - Add a translation "process.new.parameter.type.file": "file", - + // "process.new.parameter.required.missing": "The following parameters are required but still missing:", // TODO New key - Add a translation "process.new.parameter.required.missing": "The following parameters are required but still missing:", - + // "process.new.notification.success.title": "Success", // TODO New key - Add a translation "process.new.notification.success.title": "Success", - + // "process.new.notification.success.content": "The process was successfully created", // TODO New key - Add a translation "process.new.notification.success.content": "The process was successfully created", - + // "process.new.notification.error.title": "Error", // TODO New key - Add a translation "process.new.notification.error.title": "Error", - + // "process.new.notification.error.content": "An error occurred while creating this process", // TODO New key - Add a translation "process.new.notification.error.content": "An error occurred while creating this process", - + // "process.new.header": "Create a new process", // TODO New key - Add a translation "process.new.header": "Create a new process", - + // "process.new.title": "Create a new process", // TODO New key - Add a translation "process.new.title": "Create a new process", - + // "process.new.breadcrumbs": "Create a new process", // TODO New key - Add a translation "process.new.breadcrumbs": "Create a new process", - - - + + + // "process.detail.arguments" : "Arguments", // TODO New key - Add a translation "process.detail.arguments" : "Arguments", - + // "process.detail.arguments.empty" : "This process doesn't contain any arguments", // TODO New key - Add a translation "process.detail.arguments.empty" : "This process doesn't contain any arguments", - + // "process.detail.back" : "Back", // TODO New key - Add a translation "process.detail.back" : "Back", - + // "process.detail.output" : "Process Output", // TODO New key - Add a translation "process.detail.output" : "Process Output", - + // "process.detail.logs.button": "Retrieve process output", // TODO New key - Add a translation "process.detail.logs.button": "Retrieve process output", - + // "process.detail.logs.loading": "Retrieving", // TODO New key - Add a translation "process.detail.logs.loading": "Retrieving", - + // "process.detail.logs.none": "This process has no output", // TODO New key - Add a translation "process.detail.logs.none": "This process has no output", - + // "process.detail.output-files" : "Output Files", // TODO New key - Add a translation "process.detail.output-files" : "Output Files", - + // "process.detail.output-files.empty" : "This process doesn't contain any output files", // TODO New key - Add a translation "process.detail.output-files.empty" : "This process doesn't contain any output files", - + // "process.detail.script" : "Script", // TODO New key - Add a translation "process.detail.script" : "Script", - + // "process.detail.title" : "Process: {{ id }} - {{ name }}", // TODO New key - Add a translation "process.detail.title" : "Process: {{ id }} - {{ name }}", - + // "process.detail.start-time" : "Start time", // TODO New key - Add a translation "process.detail.start-time" : "Start time", - + // "process.detail.end-time" : "Finish time", // TODO New key - Add a translation "process.detail.end-time" : "Finish time", - + // "process.detail.status" : "Status", // TODO New key - Add a translation "process.detail.status" : "Status", - + // "process.detail.create" : "Create similar process", // TODO New key - Add a translation "process.detail.create" : "Create similar process", - - - + + + // "process.overview.table.finish" : "Finish time", // TODO New key - Add a translation "process.overview.table.finish" : "Finish time", - + // "process.overview.table.id" : "Process ID", // TODO New key - Add a translation "process.overview.table.id" : "Process ID", - + // "process.overview.table.name" : "Name", // TODO New key - Add a translation "process.overview.table.name" : "Name", - + // "process.overview.table.start" : "Start time", // TODO New key - Add a translation "process.overview.table.start" : "Start time", - + // "process.overview.table.status" : "Status", // TODO New key - Add a translation "process.overview.table.status" : "Status", - + // "process.overview.table.user" : "User", // TODO New key - Add a translation "process.overview.table.user" : "User", - + // "process.overview.title": "Processes Overview", // TODO New key - Add a translation "process.overview.title": "Processes Overview", - + // "process.overview.breadcrumbs": "Processes Overview", // TODO New key - Add a translation "process.overview.breadcrumbs": "Processes Overview", - + // "process.overview.new": "New", // TODO New key - Add a translation "process.overview.new": "New", - - + + // "profile.breadcrumbs": "Update Profile", "profile.breadcrumbs": "Atjaunot Profilu", - + // "profile.card.identify": "Identify", "profile.card.identify": "Identificēt", - + // "profile.card.security": "Security", "profile.card.security": "Drošība", - + // "profile.form.submit": "Update Profile", "profile.form.submit": "Atjaunot Profilu", - + // "profile.groups.head": "Authorization groups you belong to", "profile.groups.head": "Autorizācijas grupas, kurām jūs piederat", - + // "profile.head": "Update Profile", "profile.head": "Atjaunot Profilu", - + // "profile.metadata.form.error.firstname.required": "First Name is required", "profile.metadata.form.error.firstname.required": "Vārds ir nepieciešams", - + // "profile.metadata.form.error.lastname.required": "Last Name is required", "profile.metadata.form.error.lastname.required": "Uzvārds ir nepieciešams", - + // "profile.metadata.form.label.email": "Email Address", "profile.metadata.form.label.email": "E-pasta adrese", - + // "profile.metadata.form.label.firstname": "First Name", "profile.metadata.form.label.firstname": "Vārds", - + // "profile.metadata.form.label.language": "Language", "profile.metadata.form.label.language": "Valoda", - + // "profile.metadata.form.label.lastname": "Last Name", "profile.metadata.form.label.lastname": "Uzvārds", - + // "profile.metadata.form.label.phone": "Contact Telephone", "profile.metadata.form.label.phone": "Kontakttālrunis", - + // "profile.metadata.form.notifications.success.content": "Your changes to the profile were saved.", "profile.metadata.form.notifications.success.content": "Profila izmaiņas tika saglabātas.", - + // "profile.metadata.form.notifications.success.title": "Profile saved", "profile.metadata.form.notifications.success.title": "Profils saglabāts", - + // "profile.notifications.warning.no-changes.content": "No changes were made to the Profile.", "profile.notifications.warning.no-changes.content": "Profilā izmaiņas netika veiktas.", - + // "profile.notifications.warning.no-changes.title": "No changes", "profile.notifications.warning.no-changes.title": "Bez izmaiņām", - + // "profile.security.form.error.matching-passwords": "The passwords do not match.", "profile.security.form.error.matching-passwords": "Paroles nesakrīt.", - + // "profile.security.form.error.password-length": "The password should be at least 6 characters long.", "profile.security.form.error.password-length": "Parolei jābūt vismaz 6 rakstzīmju garai.", - + // "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", "profile.security.form.info": "Pēc izvēles zemāk esošajā lodziņā varat ievadīt jaunu paroli un apstiprināt to, atkārtoti ierakstot to otrajā lodziņā. Tam jābūt vismaz sešu rakstzīmju garam.", - + // "profile.security.form.label.password": "Password", "profile.security.form.label.password": "Parole", - + // "profile.security.form.label.passwordrepeat": "Retype to confirm", "profile.security.form.label.passwordrepeat": "Atkārtojiet, lai apstiprinātu", - + // "profile.security.form.notifications.success.content": "Your changes to the password were saved.", "profile.security.form.notifications.success.content": "Jūsu paroles izmaiņas tika saglabātas.", - + // "profile.security.form.notifications.success.title": "Password saved", "profile.security.form.notifications.success.title": "Parole saglabāta", - + // "profile.security.form.notifications.error.title": "Error changing passwords", "profile.security.form.notifications.error.title": "Kļūda mainot paroles", - + // "profile.security.form.notifications.error.not-long-enough": "The password has to be at least 6 characters long.", "profile.security.form.notifications.error.not-long-enough": "Parolei jābūt vismaz 6 rakstzīmju garai.", - + // "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", "profile.security.form.notifications.error.not-same": "Norādītās paroles nav vienādas.", - + // "profile.title": "Update Profile", "profile.title": "Atjaunot profilu", - - - + + + // "project.listelement.badge": "Research Project", "project.listelement.badge": "Izpētes projekts", - + // "project.page.contributor": "Contributors", "project.page.contributor": "Līdzautori", - + // "project.page.description": "Description", "project.page.description": "Apraksts", - + // "project.page.edit": "Edit this item", // TODO New key - Add a translation "project.page.edit": "Edit this item", - + // "project.page.expectedcompletion": "Expected Completion", "project.page.expectedcompletion": "Sagaidāmais pabeigšanas datums", - + // "project.page.funder": "Funders", "project.page.funder": "Izveidotāji", - + // "project.page.id": "ID", "project.page.id": "ID", - + // "project.page.keyword": "Keywords", "project.page.keyword": "Atslēgas vārdi", - + // "project.page.status": "Status", "project.page.status": "Status", - + // "project.page.titleprefix": "Research Project: ", "project.page.titleprefix": "Izpētes projekts: ", - + // "project.search.results.head": "Project Search Results", "project.search.results.head": "Projekta meklēšanas rezultāti", - - - + + + // "publication.listelement.badge": "Publication", "publication.listelement.badge": "Publikācija", - + // "publication.page.description": "Description", "publication.page.description": "Apraksts", - + // "publication.page.edit": "Edit this item", // TODO New key - Add a translation "publication.page.edit": "Edit this item", - + // "publication.page.journal-issn": "Journal ISSN", "publication.page.journal-issn": "Žurnāla ISSN", - + // "publication.page.journal-title": "Journal Title", "publication.page.journal-title": "Žurnāla Nosaukums", - + // "publication.page.publisher": "Publisher", "publication.page.publisher": "Izdevējs", - + // "publication.page.titleprefix": "Publication: ", "publication.page.titleprefix": "Publikācija: ", - + // "publication.page.volume-title": "Volume Title", "publication.page.volume-title": "Sējuma Nosaukums", - + // "publication.search.results.head": "Publication Search Results", "publication.search.results.head": "Publikāciju meklēšanas rezultāti", - + // "publication.search.title": "DSpace Angular :: Publication Search", "publication.search.title": "DSpace Angular :: Publikācijas meklēšana", - - + + // "register-email.title": "New user registration", // TODO New key - Add a translation "register-email.title": "New user registration", - + // "register-page.create-profile.header": "Create Profile", // TODO New key - Add a translation "register-page.create-profile.header": "Create Profile", - + // "register-page.create-profile.identification.header": "Identify", // TODO New key - Add a translation "register-page.create-profile.identification.header": "Identify", - + // "register-page.create-profile.identification.email": "Email Address", // TODO New key - Add a translation "register-page.create-profile.identification.email": "Email Address", - + // "register-page.create-profile.identification.first-name": "First Name *", // TODO New key - Add a translation "register-page.create-profile.identification.first-name": "First Name *", - + // "register-page.create-profile.identification.first-name.error": "Please fill in a First Name", // TODO New key - Add a translation "register-page.create-profile.identification.first-name.error": "Please fill in a First Name", - + // "register-page.create-profile.identification.last-name": "Last Name *", // TODO New key - Add a translation "register-page.create-profile.identification.last-name": "Last Name *", - + // "register-page.create-profile.identification.last-name.error": "Please fill in a Last Name", // TODO New key - Add a translation "register-page.create-profile.identification.last-name.error": "Please fill in a Last Name", - + // "register-page.create-profile.identification.contact": "Contact Telephone", // TODO New key - Add a translation "register-page.create-profile.identification.contact": "Contact Telephone", - + // "register-page.create-profile.identification.language": "Language", // TODO New key - Add a translation "register-page.create-profile.identification.language": "Language", - + // "register-page.create-profile.security.header": "Security", // TODO New key - Add a translation "register-page.create-profile.security.header": "Security", - + // "register-page.create-profile.security.info": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", // TODO New key - Add a translation "register-page.create-profile.security.info": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", - + // "register-page.create-profile.security.label.password": "Password *", // TODO New key - Add a translation "register-page.create-profile.security.label.password": "Password *", - + // "register-page.create-profile.security.label.passwordrepeat": "Retype to confirm *", // TODO New key - Add a translation "register-page.create-profile.security.label.passwordrepeat": "Retype to confirm *", - + // "register-page.create-profile.security.error.empty-password": "Please enter a password in the box below.", // TODO New key - Add a translation "register-page.create-profile.security.error.empty-password": "Please enter a password in the box below.", - + // "register-page.create-profile.security.error.matching-passwords": "The passwords do not match.", // TODO New key - Add a translation "register-page.create-profile.security.error.matching-passwords": "The passwords do not match.", - + // "register-page.create-profile.security.error.password-length": "The password should be at least 6 characters long.", // TODO New key - Add a translation "register-page.create-profile.security.error.password-length": "The password should be at least 6 characters long.", - + // "register-page.create-profile.submit": "Complete Registration", // TODO New key - Add a translation "register-page.create-profile.submit": "Complete Registration", - + // "register-page.create-profile.submit.error.content": "Something went wrong while registering a new user.", // TODO New key - Add a translation "register-page.create-profile.submit.error.content": "Something went wrong while registering a new user.", - + // "register-page.create-profile.submit.error.head": "Registration failed", // TODO New key - Add a translation "register-page.create-profile.submit.error.head": "Registration failed", - + // "register-page.create-profile.submit.success.content": "The registration was successful. You have been logged in as the created user.", // TODO New key - Add a translation "register-page.create-profile.submit.success.content": "The registration was successful. You have been logged in as the created user.", - + // "register-page.create-profile.submit.success.head": "Registration completed", // TODO New key - Add a translation "register-page.create-profile.submit.success.head": "Registration completed", - - + + // "register-page.registration.header": "New user registration", // TODO New key - Add a translation "register-page.registration.header": "New user registration", - + // "register-page.registration.info": "Register an account to subscribe to collections for email updates, and submit new items to DSpace.", // TODO New key - Add a translation "register-page.registration.info": "Register an account to subscribe to collections for email updates, and submit new items to DSpace.", - + // "register-page.registration.email": "Email Address *", // TODO New key - Add a translation "register-page.registration.email": "Email Address *", - + // "register-page.registration.email.error.required": "Please fill in an email address", // TODO New key - Add a translation "register-page.registration.email.error.required": "Please fill in an email address", - + // "register-page.registration.email.error.pattern": "Please fill in a valid email address", // TODO New key - Add a translation "register-page.registration.email.error.pattern": "Please fill in a valid email address", - + // "register-page.registration.email.hint": "This address will be verified and used as your login name.", // TODO New key - Add a translation "register-page.registration.email.hint": "This address will be verified and used as your login name.", - + // "register-page.registration.submit": "Register", // TODO New key - Add a translation "register-page.registration.submit": "Register", - + // "register-page.registration.success.head": "Verification email sent", // TODO New key - Add a translation "register-page.registration.success.head": "Verification email sent", - + // "register-page.registration.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", // TODO New key - Add a translation "register-page.registration.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", - + // "register-page.registration.error.head": "Error when trying to register email", // TODO New key - Add a translation "register-page.registration.error.head": "Error when trying to register email", - + // "register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}", // TODO New key - Add a translation "register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}", - - - + + + // "relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items", // TODO New key - Add a translation "relationships.add.error.relationship-type.content": "No suitable match could be found for relationship type {{ type }} between the two items", - + // "relationships.add.error.server.content": "The server returned an error", // TODO New key - Add a translation "relationships.add.error.server.content": "The server returned an error", - + // "relationships.add.error.title": "Unable to add relationship", // TODO New key - Add a translation "relationships.add.error.title": "Unable to add relationship", - + // "relationships.isAuthorOf": "Authors", "relationships.isAuthorOf": "Autori", - + // "relationships.isAuthorOf.Person": "Authors (persons)", // TODO New key - Add a translation "relationships.isAuthorOf.Person": "Authors (persons)", - + // "relationships.isAuthorOf.OrgUnit": "Authors (organizational units)", // TODO New key - Add a translation "relationships.isAuthorOf.OrgUnit": "Authors (organizational units)", - + // "relationships.isIssueOf": "Journal Issues", "relationships.isIssueOf": "Žurnāla Izdevumi", - + // "relationships.isJournalIssueOf": "Journal Issue", "relationships.isJournalIssueOf": "Žurnāla Izdevums", - + // "relationships.isJournalOf": "Journals", "relationships.isJournalOf": "Žurnāli", - + // "relationships.isOrgUnitOf": "Organizational Units", "relationships.isOrgUnitOf": "Struktūrvienības", - + // "relationships.isPersonOf": "Authors", "relationships.isPersonOf": "Autori", - + // "relationships.isProjectOf": "Research Projects", "relationships.isProjectOf": "Pētniecības Projekti", - + // "relationships.isPublicationOf": "Publications", "relationships.isPublicationOf": "Publikācijas", - + // "relationships.isPublicationOfJournalIssue": "Articles", "relationships.isPublicationOfJournalIssue": "Raksti", - + // "relationships.isSingleJournalOf": "Journal", "relationships.isSingleJournalOf": "Žurnāls", - + // "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isSingleVolumeOf": "Žurnāla Sējums", - + // "relationships.isVolumeOf": "Journal Volumes", "relationships.isVolumeOf": "Žurnāla Sējums", - + // "relationships.isContributorOf": "Contributors", "relationships.isContributorOf": "Līdzautori", - - - + + + // "resource-policies.add.button": "Add", // TODO New key - Add a translation "resource-policies.add.button": "Add", - + // "resource-policies.add.for.": "Add a new policy", // TODO New key - Add a translation "resource-policies.add.for.": "Add a new policy", - + // "resource-policies.add.for.bitstream": "Add a new Bitstream policy", // TODO New key - Add a translation "resource-policies.add.for.bitstream": "Add a new Bitstream policy", - + // "resource-policies.add.for.bundle": "Add a new Bundle policy", // TODO New key - Add a translation "resource-policies.add.for.bundle": "Add a new Bundle policy", - + // "resource-policies.add.for.item": "Add a new Item policy", // TODO New key - Add a translation "resource-policies.add.for.item": "Add a new Item policy", - + // "resource-policies.add.for.community": "Add a new Community policy", // TODO New key - Add a translation "resource-policies.add.for.community": "Add a new Community policy", - + // "resource-policies.add.for.collection": "Add a new Collection policy", // TODO New key - Add a translation "resource-policies.add.for.collection": "Add a new Collection policy", - + // "resource-policies.create.page.heading": "Create new resource policy for ", // TODO New key - Add a translation "resource-policies.create.page.heading": "Create new resource policy for ", - + // "resource-policies.create.page.failure.content": "An error occurred while creating the resource policy.", // TODO New key - Add a translation "resource-policies.create.page.failure.content": "An error occurred while creating the resource policy.", - + // "resource-policies.create.page.success.content": "Operation successful", // TODO New key - Add a translation "resource-policies.create.page.success.content": "Operation successful", - + // "resource-policies.create.page.title": "Create new resource policy", // TODO New key - Add a translation "resource-policies.create.page.title": "Create new resource policy", - + // "resource-policies.delete.btn": "Delete selected", // TODO New key - Add a translation "resource-policies.delete.btn": "Delete selected", - + // "resource-policies.delete.btn.title": "Delete selected resource policies", // TODO New key - Add a translation "resource-policies.delete.btn.title": "Delete selected resource policies", - + // "resource-policies.delete.failure.content": "An error occurred while deleting selected resource policies.", // TODO New key - Add a translation "resource-policies.delete.failure.content": "An error occurred while deleting selected resource policies.", - + // "resource-policies.delete.success.content": "Operation successful", // TODO New key - Add a translation "resource-policies.delete.success.content": "Operation successful", - + // "resource-policies.edit.page.heading": "Edit resource policy ", // TODO New key - Add a translation "resource-policies.edit.page.heading": "Edit resource policy ", - + // "resource-policies.edit.page.failure.content": "An error occurred while editing the resource policy.", // TODO New key - Add a translation "resource-policies.edit.page.failure.content": "An error occurred while editing the resource policy.", - + // "resource-policies.edit.page.success.content": "Operation successful", // TODO New key - Add a translation "resource-policies.edit.page.success.content": "Operation successful", - + // "resource-policies.edit.page.title": "Edit resource policy", // TODO New key - Add a translation "resource-policies.edit.page.title": "Edit resource policy", - + // "resource-policies.form.action-type.label": "Select the action type", // TODO New key - Add a translation "resource-policies.form.action-type.label": "Select the action type", - + // "resource-policies.form.action-type.required": "You must select the resource policy action.", // TODO New key - Add a translation "resource-policies.form.action-type.required": "You must select the resource policy action.", - + // "resource-policies.form.eperson-group-list.label": "The eperson or group that will be granted the permission", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.label": "The eperson or group that will be granted the permission", - + // "resource-policies.form.eperson-group-list.select.btn": "Select", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.select.btn": "Select", - + // "resource-policies.form.eperson-group-list.tab.eperson": "Search for a ePerson", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.tab.eperson": "Search for a ePerson", - + // "resource-policies.form.eperson-group-list.tab.group": "Search for a group", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.tab.group": "Search for a group", - + // "resource-policies.form.eperson-group-list.table.headers.action": "Action", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.table.headers.action": "Action", - + // "resource-policies.form.eperson-group-list.table.headers.id": "ID", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.table.headers.id": "ID", - + // "resource-policies.form.eperson-group-list.table.headers.name": "Name", // TODO New key - Add a translation "resource-policies.form.eperson-group-list.table.headers.name": "Name", - + // "resource-policies.form.date.end.label": "End Date", // TODO New key - Add a translation "resource-policies.form.date.end.label": "End Date", - + // "resource-policies.form.date.start.label": "Start Date", // TODO New key - Add a translation "resource-policies.form.date.start.label": "Start Date", - + // "resource-policies.form.description.label": "Description", // TODO New key - Add a translation "resource-policies.form.description.label": "Description", - + // "resource-policies.form.name.label": "Name", // TODO New key - Add a translation "resource-policies.form.name.label": "Name", - + // "resource-policies.form.policy-type.label": "Select the policy type", // TODO New key - Add a translation "resource-policies.form.policy-type.label": "Select the policy type", - + // "resource-policies.form.policy-type.required": "You must select the resource policy type.", // TODO New key - Add a translation "resource-policies.form.policy-type.required": "You must select the resource policy type.", - + // "resource-policies.table.headers.action": "Action", // TODO New key - Add a translation "resource-policies.table.headers.action": "Action", - + // "resource-policies.table.headers.date.end": "End Date", // TODO New key - Add a translation "resource-policies.table.headers.date.end": "End Date", - + // "resource-policies.table.headers.date.start": "Start Date", // TODO New key - Add a translation "resource-policies.table.headers.date.start": "Start Date", - + // "resource-policies.table.headers.edit": "Edit", // TODO New key - Add a translation "resource-policies.table.headers.edit": "Edit", - + // "resource-policies.table.headers.edit.group": "Edit group", // TODO New key - Add a translation "resource-policies.table.headers.edit.group": "Edit group", - + // "resource-policies.table.headers.edit.policy": "Edit policy", // TODO New key - Add a translation "resource-policies.table.headers.edit.policy": "Edit policy", - + // "resource-policies.table.headers.eperson": "EPerson", // TODO New key - Add a translation "resource-policies.table.headers.eperson": "EPerson", - + // "resource-policies.table.headers.group": "Group", // TODO New key - Add a translation "resource-policies.table.headers.group": "Group", - + // "resource-policies.table.headers.id": "ID", // TODO New key - Add a translation "resource-policies.table.headers.id": "ID", - + // "resource-policies.table.headers.name": "Name", // TODO New key - Add a translation "resource-policies.table.headers.name": "Name", - + // "resource-policies.table.headers.policyType": "type", // TODO New key - Add a translation "resource-policies.table.headers.policyType": "type", - + // "resource-policies.table.headers.title.for.bitstream": "Policies for Bitstream", // TODO New key - Add a translation "resource-policies.table.headers.title.for.bitstream": "Policies for Bitstream", - + // "resource-policies.table.headers.title.for.bundle": "Policies for Bundle", // TODO New key - Add a translation "resource-policies.table.headers.title.for.bundle": "Policies for Bundle", - + // "resource-policies.table.headers.title.for.item": "Policies for Item", // TODO New key - Add a translation "resource-policies.table.headers.title.for.item": "Policies for Item", - + // "resource-policies.table.headers.title.for.community": "Policies for Community", // TODO New key - Add a translation "resource-policies.table.headers.title.for.community": "Policies for Community", - + // "resource-policies.table.headers.title.for.collection": "Policies for Collection", // TODO New key - Add a translation "resource-policies.table.headers.title.for.collection": "Policies for Collection", - - - + + + // "search.description": "", "search.description": "", - + // "search.switch-configuration.title": "Show", "search.switch-configuration.title": "Rādīt", - + // "search.title": "DSpace Angular :: Search", "search.title": "DSpace Angular :: Meklēt", - + // "search.breadcrumbs": "Search", "search.breadcrumbs": "Meklēt", - - + + // "search.filters.applied.f.author": "Author", "search.filters.applied.f.author": "Autors", - + // "search.filters.applied.f.dateIssued.max": "End date", "search.filters.applied.f.dateIssued.max": "Beigu datums", - + // "search.filters.applied.f.dateIssued.min": "Start date", "search.filters.applied.f.dateIssued.min": "Sākuma datums", - + // "search.filters.applied.f.dateSubmitted": "Date submitted", "search.filters.applied.f.dateSubmitted": "Iesniegšanas datums", - + // "search.filters.applied.f.discoverable": "Private", "search.filters.applied.f.discoverable": "Privāts", - + // "search.filters.applied.f.entityType": "Item Type", "search.filters.applied.f.entityType": "Materiāla tips", - + // "search.filters.applied.f.has_content_in_original_bundle": "Has files", "search.filters.applied.f.has_content_in_original_bundle": "Ir faili", - + // "search.filters.applied.f.itemtype": "Type", "search.filters.applied.f.itemtype": "Tips", - + // "search.filters.applied.f.namedresourcetype": "Status", "search.filters.applied.f.namedresourcetype": "Status", - + // "search.filters.applied.f.subject": "Subject", "search.filters.applied.f.subject": "Priekšmets", - + // "search.filters.applied.f.submitter": "Submitter", "search.filters.applied.f.submitter": "Iesniedzējs", - + // "search.filters.applied.f.jobTitle": "Job Title", "search.filters.applied.f.jobTitle": "Ieņemamais Amats", - + // "search.filters.applied.f.birthDate.max": "End birth date", "search.filters.applied.f.birthDate.max": "Dzimšanas beigu datums", - + // "search.filters.applied.f.birthDate.min": "Start birth date", "search.filters.applied.f.birthDate.min": "Dzimšanas sākuma datums", - + // "search.filters.applied.f.withdrawn": "Withdrawn", "search.filters.applied.f.withdrawn": "Atsaukts", - - - + + + // "search.filters.filter.author.head": "Author", "search.filters.filter.author.head": "Autors", - + // "search.filters.filter.author.placeholder": "Author name", "search.filters.filter.author.placeholder": "Autora vārds", - + // "search.filters.filter.birthDate.head": "Birth Date", "search.filters.filter.birthDate.head": "Dzimšanas datums", - + // "search.filters.filter.birthDate.placeholder": "Birth Date", "search.filters.filter.birthDate.placeholder": "Dzimšanas datums", - + // "search.filters.filter.creativeDatePublished.head": "Date Published", "search.filters.filter.creativeDatePublished.head": "Publicēšanas datums", - + // "search.filters.filter.creativeDatePublished.placeholder": "Date Published", "search.filters.filter.creativeDatePublished.placeholder": "Publicēšanas datums", - + // "search.filters.filter.creativeWorkEditor.head": "Editor", "search.filters.filter.creativeWorkEditor.head": "Redaktors", - + // "search.filters.filter.creativeWorkEditor.placeholder": "Editor", "search.filters.filter.creativeWorkEditor.placeholder": "Redaktors", - + // "search.filters.filter.creativeWorkKeywords.head": "Subject", "search.filters.filter.creativeWorkKeywords.head": "Priekšmets", - + // "search.filters.filter.creativeWorkKeywords.placeholder": "Subject", "search.filters.filter.creativeWorkKeywords.placeholder": "Priekšmets", - + // "search.filters.filter.creativeWorkPublisher.head": "Publisher", "search.filters.filter.creativeWorkPublisher.head": "Izdevējs", - + // "search.filters.filter.creativeWorkPublisher.placeholder": "Publisher", "search.filters.filter.creativeWorkPublisher.placeholder": "Izdevējs", - + // "search.filters.filter.dateIssued.head": "Date", "search.filters.filter.dateIssued.head": "Datums", - + // "search.filters.filter.dateIssued.max.placeholder": "Minimum Date", "search.filters.filter.dateIssued.max.placeholder": "Minimālais datums", - + // "search.filters.filter.dateIssued.min.placeholder": "Maximum Date", "search.filters.filter.dateIssued.min.placeholder": "Maksimālais datums", - + // "search.filters.filter.dateSubmitted.head": "Date submitted", "search.filters.filter.dateSubmitted.head": "Iesniegšanas datums", - + // "search.filters.filter.dateSubmitted.placeholder": "Date submitted", "search.filters.filter.dateSubmitted.placeholder": "Iesniegšanas datums", - + // "search.filters.filter.discoverable.head": "Private", "search.filters.filter.discoverable.head": "Privāts", - + // "search.filters.filter.withdrawn.head": "Withdrawn", "search.filters.filter.withdrawn.head": "Atsaukts", - + // "search.filters.filter.entityType.head": "Item Type", "search.filters.filter.entityType.head": "Materiāla tips", - + // "search.filters.filter.entityType.placeholder": "Item Type", "search.filters.filter.entityType.placeholder": "Materiāla tips", - + // "search.filters.filter.has_content_in_original_bundle.head": "Has files", "search.filters.filter.has_content_in_original_bundle.head": "Ir faili", - + // "search.filters.filter.itemtype.head": "Type", "search.filters.filter.itemtype.head": "Tips", - + // "search.filters.filter.itemtype.placeholder": "Type", "search.filters.filter.itemtype.placeholder": "Tips", - + // "search.filters.filter.jobTitle.head": "Job Title", "search.filters.filter.jobTitle.head": "Ieņemamais Amats", - + // "search.filters.filter.jobTitle.placeholder": "Job Title", "search.filters.filter.jobTitle.placeholder": "Ieņemamais Amats", - + // "search.filters.filter.knowsLanguage.head": "Known language", "search.filters.filter.knowsLanguage.head": "Pārvalda valodu", - + // "search.filters.filter.knowsLanguage.placeholder": "Known language", "search.filters.filter.knowsLanguage.placeholder": "Pārvalda valodu", - + // "search.filters.filter.namedresourcetype.head": "Status", "search.filters.filter.namedresourcetype.head": "Status", - + // "search.filters.filter.namedresourcetype.placeholder": "Status", "search.filters.filter.namedresourcetype.placeholder": "Status", - + // "search.filters.filter.objectpeople.head": "People", "search.filters.filter.objectpeople.head": "Personas", - + // "search.filters.filter.objectpeople.placeholder": "People", "search.filters.filter.objectpeople.placeholder": "Personas", - + // "search.filters.filter.organizationAddressCountry.head": "Country", "search.filters.filter.organizationAddressCountry.head": "Valsts", - + // "search.filters.filter.organizationAddressCountry.placeholder": "Country", "search.filters.filter.organizationAddressCountry.placeholder": "Valsts", - + // "search.filters.filter.organizationAddressLocality.head": "City", "search.filters.filter.organizationAddressLocality.head": "Pilsēta", - + // "search.filters.filter.organizationAddressLocality.placeholder": "City", "search.filters.filter.organizationAddressLocality.placeholder": "Pilsēta", - + // "search.filters.filter.organizationFoundingDate.head": "Date Founded", "search.filters.filter.organizationFoundingDate.head": "Dibināšanas datums", - + // "search.filters.filter.organizationFoundingDate.placeholder": "Date Founded", "search.filters.filter.organizationFoundingDate.placeholder": "Dibināšanas datums", - + // "search.filters.filter.scope.head": "Scope", "search.filters.filter.scope.head": "Joma", - + // "search.filters.filter.scope.placeholder": "Scope filter", "search.filters.filter.scope.placeholder": "Jomas filtrs", - + // "search.filters.filter.show-less": "Collapse", "search.filters.filter.show-less": "Sakļaut", - + // "search.filters.filter.show-more": "Show more", "search.filters.filter.show-more": "Rādīt vairāk", - + // "search.filters.filter.subject.head": "Subject", "search.filters.filter.subject.head": "Priekšmets", - + // "search.filters.filter.subject.placeholder": "Subject", "search.filters.filter.subject.placeholder": "Priekšmets", - + // "search.filters.filter.submitter.head": "Submitter", "search.filters.filter.submitter.head": "Iesniedzējs", - + // "search.filters.filter.submitter.placeholder": "Submitter", "search.filters.filter.submitter.placeholder": "Iesniedzējs", - - - + + + // "search.filters.entityType.JournalIssue": "Journal Issue", "search.filters.entityType.JournalIssue": "Žurnāla Izdevums", - + // "search.filters.entityType.JournalVolume": "Journal Volume", "search.filters.entityType.JournalVolume": "Žurnāla Sējum", - + // "search.filters.entityType.OrgUnit": "Organizational Unit", "search.filters.entityType.OrgUnit": "Struktūrvienība", - + // "search.filters.has_content_in_original_bundle.true": "Yes", "search.filters.has_content_in_original_bundle.true": "Jā", - + // "search.filters.has_content_in_original_bundle.false": "No", "search.filters.has_content_in_original_bundle.false": "Nē", - + // "search.filters.discoverable.true": "No", "search.filters.discoverable.true": "Nē", - + // "search.filters.discoverable.false": "Yes", "search.filters.discoverable.false": "Jā", - + // "search.filters.withdrawn.true": "Yes", "search.filters.withdrawn.true": "Jā", - + // "search.filters.withdrawn.false": "No", "search.filters.withdrawn.false": "Nē", - - + + // "search.filters.head": "Filters", "search.filters.head": "Filtri", - + // "search.filters.reset": "Reset filters", "search.filters.reset": "Atiestatīt filtrus", - - - + + + // "search.form.search": "Search", "search.form.search": "Meklēt", - + // "search.form.search_dspace": "Search DSpace", "search.form.search_dspace": "Meklēt DSpace", - + // "search.form.search_mydspace": "Search MyDSpace", "search.form.search_mydspace": "Meklēt manā DSpace", - - - + + + // "search.results.head": "Search Results", "search.results.head": "Meklēšanas rezultāti", - + // "search.results.no-results": "Your search returned no results. Having trouble finding what you're looking for? Try putting", "search.results.no-results": "Rezultāti netika atrasti. Vai jums ir grūtības atrast meklēto? Mēģiniet ievietot", - + // "search.results.no-results-link": "quotes around it", "search.results.no-results-link": "pēdiņas ap to", - + // "search.results.empty": "Your search returned no results.", "search.results.empty": "Rezultāti netika atrasti.", - - - + + + // "search.sidebar.close": "Back to results", "search.sidebar.close": "Atpakaļ pie rezultātiem", - + // "search.sidebar.filters.title": "Filters", "search.sidebar.filters.title": "Filtri", - + // "search.sidebar.open": "Search Tools", "search.sidebar.open": "Meklēšanas Rīki", - + // "search.sidebar.results": "results", "search.sidebar.results": "rezultāti", - + // "search.sidebar.settings.rpp": "Results per page", "search.sidebar.settings.rpp": "Rezultāti vienā lapā", - + // "search.sidebar.settings.sort-by": "Sort By", "search.sidebar.settings.sort-by": "Kārtot Pēc", - + // "search.sidebar.settings.title": "Settings", "search.sidebar.settings.title": "Iestatījumi", - - - + + + // "search.view-switch.show-detail": "Show detail", "search.view-switch.show-detail": "Attēlot detaļas", - + // "search.view-switch.show-grid": "Show as grid", "search.view-switch.show-grid": "Attēlot matricā", - + // "search.view-switch.show-list": "Show as list", "search.view-switch.show-list": "Attēlot kā sarakstu", - - - + + + // "sorting.ASC": "Ascending", // TODO New key - Add a translation "sorting.ASC": "Ascending", - + // "sorting.DESC": "Descending", // TODO New key - Add a translation "sorting.DESC": "Descending", - + // "sorting.dc.title.ASC": "Title Ascending", "sorting.dc.title.ASC": "Nosaukums augošā secībā", - + // "sorting.dc.title.DESC": "Title Descending", "sorting.dc.title.DESC": "Nosaukums dilstošā secībā", - + // "sorting.score.DESC": "Relevance", "sorting.score.DESC": "Atbilstība", - - - + + + // "statistics.title": "Statistics", // TODO New key - Add a translation "statistics.title": "Statistics", - + // "statistics.header": "Statistics for {{ scope }}", // TODO New key - Add a translation "statistics.header": "Statistics for {{ scope }}", - + // "statistics.breadcrumbs": "Statistics", // TODO New key - Add a translation "statistics.breadcrumbs": "Statistics", - + // "statistics.page.no-data": "No data available", // TODO New key - Add a translation "statistics.page.no-data": "No data available", - + // "statistics.table.no-data": "No data available", // TODO New key - Add a translation "statistics.table.no-data": "No data available", - + // "statistics.table.title.TotalVisits": "Total visits", // TODO New key - Add a translation "statistics.table.title.TotalVisits": "Total visits", - + // "statistics.table.title.TotalVisitsPerMonth": "Total visits per month", // TODO New key - Add a translation "statistics.table.title.TotalVisitsPerMonth": "Total visits per month", - + // "statistics.table.title.TotalDownloads": "File Visits", // TODO New key - Add a translation "statistics.table.title.TotalDownloads": "File Visits", - + // "statistics.table.title.TopCountries": "Top country views", // TODO New key - Add a translation "statistics.table.title.TopCountries": "Top country views", - + // "statistics.table.title.TopCities": "Top city views", // TODO New key - Add a translation "statistics.table.title.TopCities": "Top city views", - + // "statistics.table.header.views": "Views", // TODO New key - Add a translation "statistics.table.header.views": "Views", - - - + + + // "submission.edit.title": "Edit Submission", "submission.edit.title": "Rediģēt Iesniegumu", - + // "submission.general.cannot_submit": "You have not the privilege to make a new submission.", "submission.general.cannot_submit": "Jums nav tiesību iesniegt jaunu iesniegumu.", - + // "submission.general.deposit": "Deposit", "submission.general.deposit": "Ievietot", - + // "submission.general.discard.confirm.cancel": "Cancel", "submission.general.discard.confirm.cancel": "Atcelt", - + // "submission.general.discard.confirm.info": "This operation can't be undone. Are you sure?", "submission.general.discard.confirm.info": "Šo darbību nevar atsaukt. Vai tu esat pārliecināts?", - + // "submission.general.discard.confirm.submit": "Yes, I'm sure", "submission.general.discard.confirm.submit": "Jā, esmu pārliecināts", - + // "submission.general.discard.confirm.title": "Discard submission", "submission.general.discard.confirm.title": "Atcelt ievietošanu", - + // "submission.general.discard.submit": "Discard", "submission.general.discard.submit": "Atcelt", - + // "submission.general.save": "Save", "submission.general.save": "Saglabāt", - + // "submission.general.save-later": "Save for later", "submission.general.save-later": "Saglabāt vēlākam", - - + + // "submission.import-external.page.title": "Import metadata from an external source", // TODO New key - Add a translation "submission.import-external.page.title": "Import metadata from an external source", - + // "submission.import-external.title": "Import metadata from an external source", // TODO New key - Add a translation "submission.import-external.title": "Import metadata from an external source", - + // "submission.import-external.page.hint": "Enter a query above to find items from the web to import in to DSpace.", // TODO New key - Add a translation "submission.import-external.page.hint": "Enter a query above to find items from the web to import in to DSpace.", - + // "submission.import-external.back-to-my-dspace": "Back to MyDSpace", // TODO New key - Add a translation "submission.import-external.back-to-my-dspace": "Back to MyDSpace", - + // "submission.import-external.search.placeholder": "Search the external source", // TODO New key - Add a translation "submission.import-external.search.placeholder": "Search the external source", - + // "submission.import-external.search.button": "Search", // TODO New key - Add a translation "submission.import-external.search.button": "Search", - + // "submission.import-external.search.button.hint": "Write some words to search", // TODO New key - Add a translation "submission.import-external.search.button.hint": "Write some words to search", - + // "submission.import-external.search.source.hint": "Pick an external source", // TODO New key - Add a translation "submission.import-external.search.source.hint": "Pick an external source", - + // "submission.import-external.source.arxiv": "arXiv", // TODO New key - Add a translation "submission.import-external.source.arxiv": "arXiv", - + // "submission.import-external.source.loading": "Loading ...", // TODO New key - Add a translation "submission.import-external.source.loading": "Loading ...", - + // "submission.import-external.source.sherpaJournal": "SHERPA Journals", // TODO New key - Add a translation "submission.import-external.source.sherpaJournal": "SHERPA Journals", - + // "submission.import-external.source.sherpaPublisher": "SHERPA Publishers", // TODO New key - Add a translation "submission.import-external.source.sherpaPublisher": "SHERPA Publishers", - + // "submission.import-external.source.orcid": "ORCID", // TODO New key - Add a translation "submission.import-external.source.orcid": "ORCID", - + // "submission.import-external.source.pubmed": "Pubmed", // TODO New key - Add a translation "submission.import-external.source.pubmed": "Pubmed", - + // "submission.import-external.source.lcname": "Library of Congress Names", // TODO New key - Add a translation "submission.import-external.source.lcname": "Library of Congress Names", - + // "submission.import-external.preview.title": "Item Preview", // TODO New key - Add a translation "submission.import-external.preview.title": "Item Preview", - + // "submission.import-external.preview.subtitle": "The metadata below was imported from an external source. It will be pre-filled when you start the submission.", // TODO New key - Add a translation "submission.import-external.preview.subtitle": "The metadata below was imported from an external source. It will be pre-filled when you start the submission.", - + // "submission.import-external.preview.button.import": "Start submission", // TODO New key - Add a translation "submission.import-external.preview.button.import": "Start submission", - + // "submission.import-external.preview.error.import.title": "Submission error", // TODO New key - Add a translation "submission.import-external.preview.error.import.title": "Submission error", - + // "submission.import-external.preview.error.import.body": "An error occurs during the external source entry import process.", // TODO New key - Add a translation "submission.import-external.preview.error.import.body": "An error occurs during the external source entry import process.", - + // "submission.sections.describe.relationship-lookup.close": "Close", "submission.sections.describe.relationship-lookup.close": "Aizvērt", - + // "submission.sections.describe.relationship-lookup.external-source.added": "Successfully added local entry to the selection", "submission.sections.describe.relationship-lookup.external-source.added": "Lokālais ieraksts veiksmigi pievienots izvēlei", - + // "submission.sections.describe.relationship-lookup.external-source.import-button-title.isAuthorOfPublication": "Import remote author", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-button-title.isAuthorOfPublication": "Import remote author", - + // "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal": "Import remote journal", "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal": "Importēt attālinātu žurnālu", - + // "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Issue": "Import remote journal issue", "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Issue": "Importēt attālināta žurnāla izdevumu", - + // "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Volume": "Import remote journal volume", "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Volume": "Importēt attālinātā žurnāla sējumu", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.title": "Import Remote Author", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.title": "Import Remote Author", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.added.local-entity": "Successfully added local author to the selection", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.added.local-entity": "Successfully added local author to the selection", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.added.new-entity": "Successfully imported and added external author to the selection", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-modal.isAuthorOfPublication.added.new-entity": "Successfully imported and added external author to the selection", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.authority": "Authority", "submission.sections.describe.relationship-lookup.external-source.import-modal.authority": "Autoritāte", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.authority.new": "Import as a new local authority entry", "submission.sections.describe.relationship-lookup.external-source.import-modal.authority.new": "Importēt kā jaunu lokālās autoritātes ierakstu", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.cancel": "Cancel", "submission.sections.describe.relationship-lookup.external-source.import-modal.cancel": "Atcelt", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.collection": "Select a collection to import new entries to", "submission.sections.describe.relationship-lookup.external-source.import-modal.collection": "Atlasiet kolekciju, kurā importēt jaunus ierakstus", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.entities": "Entities", "submission.sections.describe.relationship-lookup.external-source.import-modal.entities": "Vienības", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.entities.new": "Import as a new local entity", "submission.sections.describe.relationship-lookup.external-source.import-modal.entities.new": "Importēt kā jaunu vietējo vienību", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.lcname": "Importing from LC Name", "submission.sections.describe.relationship-lookup.external-source.import-modal.head.lcname": "Importē no LC Nosaukuma", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.orcid": "Importing from ORCID", "submission.sections.describe.relationship-lookup.external-source.import-modal.head.orcid": "Importē no ORCID", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaJournal": "Importing from Sherpa Journal", "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaJournal": "Importē no Sherpa Žurnāla", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaPublisher": "Importing from Sherpa Publisher", "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaPublisher": "Importēt no Sherpa Izdevēja", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.pubmed": "Importing from PubMed", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-modal.head.pubmed": "Importing from PubMed", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.head.arxiv": "Importing from arXiv", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.external-source.import-modal.head.arxiv": "Importing from arXiv", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.import": "Import", "submission.sections.describe.relationship-lookup.external-source.import-modal.import": "Importēt", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.title": "Import Remote Journal", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.title": "Importēt Attālinātu Žurnālu", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.local-entity": "Successfully added local journal to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.local-entity": "Lokālais žurnāls veiksmīgi pievienots izlasei", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.new-entity": "Successfully imported and added external journal to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.new-entity": "Veiksmīgi importēts un pievienots ārējais žurnāls atlasē", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.title": "Import Remote Journal Issue", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.title": "Importēt Attālināta Žurnāla Izdevumu", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.local-entity": "Successfully added local journal issue to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.local-entity": "Lokālā žurnāla izdevums veiksmīgi pievienots izlasei", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.new-entity": "Successfully imported and added external journal issue to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.new-entity": "Veiksmīgi importēts un atlasē pievienots ārēja žurnāla izdevums", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.title": "Import Remote Journal Volume", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.title": "Importēt Attālinātā Žurnāla Sējumu", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.local-entity": "Successfully added local journal volume to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.local-entity": "Lokālā žurnāla sējums veiksmīgi pievienots atlasē", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.new-entity": "Successfully imported and added external journal volume to the selection", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.new-entity": "Veiksmīgi importēts un atlasē pievienots ārējā žurnāla sējums", - + // "submission.sections.describe.relationship-lookup.external-source.import-modal.select": "Select a local match:", "submission.sections.describe.relationship-lookup.external-source.import-modal.select": "Atlasīt lokālo sakritību:", - + // "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Deselect all", "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Atcelt izvēli", - + // "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Deselect page", "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Atcelt izvēlēto lapu", - + // "submission.sections.describe.relationship-lookup.search-tab.loading": "Loading...", "submission.sections.describe.relationship-lookup.search-tab.loading": "Notiek ielāde...", - + // "submission.sections.describe.relationship-lookup.search-tab.placeholder": "Search query", "submission.sections.describe.relationship-lookup.search-tab.placeholder": "Meklēšanas vaicājums", - + // "submission.sections.describe.relationship-lookup.search-tab.search": "Go", "submission.sections.describe.relationship-lookup.search-tab.search": "Izpildīt", - + // "submission.sections.describe.relationship-lookup.search-tab.select-all": "Select all", "submission.sections.describe.relationship-lookup.search-tab.select-all": "Izvēlēties visus", - + // "submission.sections.describe.relationship-lookup.search-tab.select-page": "Select page", "submission.sections.describe.relationship-lookup.search-tab.select-page": "Izvēlēties lapu", - + // "submission.sections.describe.relationship-lookup.selected": "Selected {{ size }} items", "submission.sections.describe.relationship-lookup.selected": "Izvēlētie {{ size }} materiāli", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isAuthorOfPublication": "Local Authors ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isAuthorOfPublication": "Local Authors ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalOfPublication": "Local Journals ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalOfPublication": "Local Journals ({{ count }})", // "submission.sections.describe.relationship-lookup.search-tab.tab-title.Project": "Local Projects ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.Project": "Local Projects ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.Publication": "Local Publications ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.Publication": "Local Publications ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.Person": "Local Authors ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.Person": "Local Authors ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.OrgUnit": "Local Organizational Units ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.OrgUnit": "Local Organizational Units ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.DataPackage": "Local Data Packages ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.DataPackage": "Local Data Packages ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.DataFile": "Local Data Files ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.DataFile": "Local Data Files ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal": "Local Journals ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal": "Vietējie Žurnāli ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalIssueOfPublication": "Local Journal Issues ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalIssueOfPublication": "Local Journal Issues ({{ count }})", // "submission.sections.describe.relationship-lookup.search-tab.tab-title.JournalIssue": "Local Journal Issues ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.JournalIssue": "Local Journal Issues ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalVolumeOfPublication": "Local Journal Volumes ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isJournalVolumeOfPublication": "Local Journal Volumes ({{ count }})", // "submission.sections.describe.relationship-lookup.search-tab.tab-title.JournalVolume": "Local Journal Volumes ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.JournalVolume": "Local Journal Volumes ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.sherpaJournal": "Sherpa Journals ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.sherpaJournal": "Sherpa Žurnāli ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.sherpaPublisher": "Sherpa Publishers ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.sherpaPublisher": "Sherpa Izdevēji ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.orcid": "ORCID ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.orcid": "ORCID ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.lcname": "LC Names ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.lcname": "LC Nosaukumi ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.pubmed": "PubMed ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.pubmed": "PubMed ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.arxiv": "arXiv ({{ count }})", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.arxiv": "arXiv ({{ count }})", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingAgencyOfPublication": "Search for Funding Agencies", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingAgencyOfPublication": "Search for Funding Agencies", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingOfPublication": "Search for Funding", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isFundingOfPublication": "Search for Funding", - + // "submission.sections.describe.relationship-lookup.search-tab.tab-title.isChildOrgUnitOf": "Search for Organizational Units", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.search-tab.tab-title.isChildOrgUnitOf": "Search for Organizational Units", - + // "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})", "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Pašreiz Izvēlēti ({{ count }})", - + // "submission.sections.describe.relationship-lookup.title.isJournalIssueOfPublication": "Journal Issues", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isJournalIssueOfPublication": "Journal Issues", // "submission.sections.describe.relationship-lookup.title.JournalIssue": "Journal Issues", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.JournalIssue": "Journal Issues", - + // "submission.sections.describe.relationship-lookup.title.isJournalVolumeOfPublication": "Journal Volumes", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isJournalVolumeOfPublication": "Journal Volumes", // "submission.sections.describe.relationship-lookup.title.JournalVolume": "Journal Volumes", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.JournalVolume": "Journal Volumes", - + // "submission.sections.describe.relationship-lookup.title.isJournalOfPublication": "Journals", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isJournalOfPublication": "Journals", - + // "submission.sections.describe.relationship-lookup.title.isAuthorOfPublication": "Authors", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isAuthorOfPublication": "Authors", - + // "submission.sections.describe.relationship-lookup.title.isFundingAgencyOfPublication": "Funding Agency", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isFundingAgencyOfPublication": "Funding Agency", // "submission.sections.describe.relationship-lookup.title.Project": "Projects", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.Project": "Projects", - + // "submission.sections.describe.relationship-lookup.title.Publication": "Publications", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.Publication": "Publications", - + // "submission.sections.describe.relationship-lookup.title.Person": "Authors", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.Person": "Authors", - + // "submission.sections.describe.relationship-lookup.title.OrgUnit": "Organizational Units", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.OrgUnit": "Organizational Units", - + // "submission.sections.describe.relationship-lookup.title.DataPackage": "Data Packages", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.DataPackage": "Data Packages", - + // "submission.sections.describe.relationship-lookup.title.DataFile": "Data Files", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.DataFile": "Data Files", - + // "submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency", "submission.sections.describe.relationship-lookup.title.Funding Agency": "Finansējošā aģēntūra", - + // "submission.sections.describe.relationship-lookup.title.isFundingOfPublication": "Funding", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isFundingOfPublication": "Funding", - + // "submission.sections.describe.relationship-lookup.title.isChildOrgUnitOf": "Parent Organizational Unit", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.title.isChildOrgUnitOf": "Parent Organizational Unit", - + // "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown", "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Pārslēgšanas nolaižamā izvēlne", - + // "submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings", "submission.sections.describe.relationship-lookup.selection-tab.settings": "Iestatījumi", - + // "submission.sections.describe.relationship-lookup.selection-tab.no-selection": "Your selection is currently empty.", "submission.sections.describe.relationship-lookup.selection-tab.no-selection": "ūsu atlase pašlaik ir tukša.", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isAuthorOfPublication": "Selected Authors", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isAuthorOfPublication": "Selected Authors", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalOfPublication": "Selected Journals", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalOfPublication": "Selected Journals", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalVolumeOfPublication": "Selected Journal Volume", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalVolumeOfPublication": "Selected Journal Volume", // "submission.sections.describe.relationship-lookup.selection-tab.title.Project": "Selected Projects", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.Project": "Selected Projects", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.Publication": "Selected Publications", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.Publication": "Selected Publications", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.Person": "Selected Authors", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.Person": "Selected Authors", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.OrgUnit": "Selected Organizational Units", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.OrgUnit": "Selected Organizational Units", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.DataPackage": "Selected Data Packages", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.DataPackage": "Selected Data Packages", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.DataFile": "Selected Data Files", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.DataFile": "Selected Data Files", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.Journal": "Selected Journals", "submission.sections.describe.relationship-lookup.selection-tab.title.Journal": "Izvēlētais Žurnāls", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalIssueOfPublication": "Selected Issue", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isJournalIssueOfPublication": "Selected Issue", // "submission.sections.describe.relationship-lookup.selection-tab.title.JournalVolume": "Selected Journal Volume", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.JournalVolume": "Selected Journal Volume", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingAgencyOfPublication": "Selected Funding Agency", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingAgencyOfPublication": "Selected Funding Agency", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingOfPublication": "Selected Funding", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isFundingOfPublication": "Selected Funding", // "submission.sections.describe.relationship-lookup.selection-tab.title.JournalIssue": "Selected Issue", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.JournalIssue": "Selected Issue", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.isChildOrgUnitOf": "Selected Organizational Unit", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.isChildOrgUnitOf": "Selected Organizational Unit", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.sherpaJournal": "Search Results", "submission.sections.describe.relationship-lookup.selection-tab.title.sherpaJournal": "Meklēšanas rezultāti", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.sherpaPublisher": "Search Results", "submission.sections.describe.relationship-lookup.selection-tab.title.sherpaPublisher": "Meklēšanas rezultāti", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.orcid": "Search Results", "submission.sections.describe.relationship-lookup.selection-tab.title.orcid": "Meklēšanas rezultāti", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.orcidv2": "Search Results", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.orcidv2": "Search Results", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.lcname": "Search Results", "submission.sections.describe.relationship-lookup.selection-tab.title.lcname": "Meklēšanas rezultāti", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.pubmed": "Search Results", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.pubmed": "Search Results", - + // "submission.sections.describe.relationship-lookup.selection-tab.title.arxiv": "Search Results", // TODO New key - Add a translation "submission.sections.describe.relationship-lookup.selection-tab.title.arxiv": "Search Results", - + // "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Would you like to save \"{{ value }}\" as a name variant for this person so you and others can reuse it for future submissions? If you don\'t you can still use it for this submission.", "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Vai vēlaties saglabāt \"{{ value }}\" kā vārda variantu šai personai, lai jūs un citi varētu to izmantot turpmākai iesniegšanai? Ja nē, jūs joprojām varat to izmantot šai iesniegšanai.", - + // "submission.sections.describe.relationship-lookup.name-variant.notification.confirm": "Save a new name variant", "submission.sections.describe.relationship-lookup.name-variant.notification.confirm": "Saglabājiet jauna nosaukuma variantu", - + // "submission.sections.describe.relationship-lookup.name-variant.notification.decline": "Use only for this submission", "submission.sections.describe.relationship-lookup.name-variant.notification.decline": "Izmantojiet tikai pašreizējai iesniegšanai", - + // "submission.sections.ccLicense.type": "License Type", // TODO New key - Add a translation "submission.sections.ccLicense.type": "License Type", - + // "submission.sections.ccLicense.select": "Select a license type…", // TODO New key - Add a translation "submission.sections.ccLicense.select": "Select a license type…", - + // "submission.sections.ccLicense.change": "Change your license type…", // TODO New key - Add a translation "submission.sections.ccLicense.change": "Change your license type…", - + // "submission.sections.ccLicense.none": "No licenses available", // TODO New key - Add a translation "submission.sections.ccLicense.none": "No licenses available", - + // "submission.sections.ccLicense.option.select": "Select an option…", // TODO New key - Add a translation "submission.sections.ccLicense.option.select": "Select an option…", - + // "submission.sections.ccLicense.link": "You’ve selected the following license:", // TODO New key - Add a translation "submission.sections.ccLicense.link": "You’ve selected the following license:", - + // "submission.sections.ccLicense.confirmation": "I grant the license above", // TODO New key - Add a translation "submission.sections.ccLicense.confirmation": "I grant the license above", - + // "submission.sections.general.add-more": "Add more", "submission.sections.general.add-more": "Pievienot vēl", - + // "submission.sections.general.collection": "Collection", "submission.sections.general.collection": "Kolekcija", - + // "submission.sections.general.deposit_error_notice": "There was an issue when submitting the item, please try again later.", "submission.sections.general.deposit_error_notice": "Iesniedzot materiālu, radās problēma. Lūdzu, vēlāk mēģiniet vēlreiz.", - + // "submission.sections.general.deposit_success_notice": "Submission deposited successfully.", "submission.sections.general.deposit_success_notice": "Iesniegums ir veiksmīgi iesniegts.", - + // "submission.sections.general.discard_error_notice": "There was an issue when discarding the item, please try again later.", "submission.sections.general.discard_error_notice": "Izmetot vienumu, radās kļūda. Lūdzu, vēlāk mēģiniet vēlreiz", - + // "submission.sections.general.discard_success_notice": "Submission discarded successfully.", "submission.sections.general.discard_success_notice": "Iesniegums atmests.", - + // "submission.sections.general.metadata-extracted": "New metadata have been extracted and added to the {{sectionId}} section.", "submission.sections.general.metadata-extracted": "Jauni metadati ir iegūti un pievienoti {{sectionId}} sadaļā.", - + // "submission.sections.general.metadata-extracted-new-section": "New {{sectionId}} section has been added to submission.", "submission.sections.general.metadata-extracted-new-section": "Jauna {{sectionId}} sadaļa ir pievienota iesniegšanai.", - + // "submission.sections.general.no-collection": "No collection found", "submission.sections.general.no-collection": "Kolekcijas nav atrastas", - + // "submission.sections.general.no-sections": "No options available", "submission.sections.general.no-sections": "Opcijas nav pieejamas", - + // "submission.sections.general.save_error_notice": "There was an issue when saving the item, please try again later.", "submission.sections.general.save_error_notice": "Saglabājot materiālu, radās problēma. Lūdzu, vēlāk mēģiniet vēlreiz.", - + // "submission.sections.general.save_success_notice": "Submission saved successfully.", "submission.sections.general.save_success_notice": "Iesniegums veiksmīgi saglabāts.", - + // "submission.sections.general.search-collection": "Search for a collection", "submission.sections.general.search-collection": "Meklēt kolekciju", - + // "submission.sections.general.sections_not_valid": "There are incomplete sections.", "submission.sections.general.sections_not_valid": "Ir nepilnīgas sadaļas.", - - - + + + // "submission.sections.submit.progressbar.CClicense": "Creative commons license", // TODO New key - Add a translation "submission.sections.submit.progressbar.CClicense": "Creative commons license", - + // "submission.sections.submit.progressbar.describe.recycle": "Recycle", "submission.sections.submit.progressbar.describe.recycle": "Pārstrādāt", - + // "submission.sections.submit.progressbar.describe.stepcustom": "Describe", "submission.sections.submit.progressbar.describe.stepcustom": "Aprakstiet", - + // "submission.sections.submit.progressbar.describe.stepone": "Describe", "submission.sections.submit.progressbar.describe.stepone": "Aprakstiet", - + // "submission.sections.submit.progressbar.describe.steptwo": "Describe", "submission.sections.submit.progressbar.describe.steptwo": "Aprakstiet", - + // "submission.sections.submit.progressbar.detect-duplicate": "Potential duplicates", "submission.sections.submit.progressbar.detect-duplicate": "Potenciālie dublikāti", - + // "submission.sections.submit.progressbar.license": "Deposit license", "submission.sections.submit.progressbar.license": "Ievietot licenci", - + // "submission.sections.submit.progressbar.upload": "Upload files", "submission.sections.submit.progressbar.upload": "Augšupielādēt failus", - - - + + + // "submission.sections.upload.delete.confirm.cancel": "Cancel", "submission.sections.upload.delete.confirm.cancel": "Atcelt", - + // "submission.sections.upload.delete.confirm.info": "This operation can't be undone. Are you sure?", "submission.sections.upload.delete.confirm.info": "Šo darbību nevar atsaukt. Vai tu esi pārliecināts?", - + // "submission.sections.upload.delete.confirm.submit": "Yes, I'm sure", "submission.sections.upload.delete.confirm.submit": "Jā, esmu pārliecināts", - + // "submission.sections.upload.delete.confirm.title": "Delete bitstream", "submission.sections.upload.delete.confirm.title": "Dzēst bitu straumes", - + // "submission.sections.upload.delete.submit": "Delete", "submission.sections.upload.delete.submit": "Dzēst", - + // "submission.sections.upload.drop-message": "Drop files to attach them to the item", "submission.sections.upload.drop-message": "Ievietojiet failu, lai pievienotu materiālam", - + // "submission.sections.upload.form.access-condition-label": "Access condition type", "submission.sections.upload.form.access-condition-label": "Piekļuves nosacījuma tips", - + // "submission.sections.upload.form.date-required": "Date is required.", "submission.sections.upload.form.date-required": "Datums ir nepieciešams.", - + // "submission.sections.upload.form.from-label": "Grant access from", // TODO Source message changed - Revise the translation "submission.sections.upload.form.from-label": "Iespēja izmantot no", - + // "submission.sections.upload.form.from-placeholder": "From", "submission.sections.upload.form.from-placeholder": "No", - + // "submission.sections.upload.form.group-label": "Group", "submission.sections.upload.form.group-label": "Grupa", - + // "submission.sections.upload.form.group-required": "Group is required.", "submission.sections.upload.form.group-required": "Nepieciešama grupa.", - + // "submission.sections.upload.form.until-label": "Grant access until", // TODO Source message changed - Revise the translation "submission.sections.upload.form.until-label": "Piekļuvas atļauja līdz", - + // "submission.sections.upload.form.until-placeholder": "Until", "submission.sections.upload.form.until-placeholder": "Līdz", - + // "submission.sections.upload.header.policy.default.nolist": "Uploaded files in the {{collectionName}} collection will be accessible according to the following group(s):", "submission.sections.upload.header.policy.default.nolist": "Augšupielādētie faili kolekcijā {{collectionName}} būs pieejami atbilstoši šīm grupām:", - + // "submission.sections.upload.header.policy.default.withlist": "Please note that uploaded files in the {{collectionName}} collection will be accessible, in addition to what is explicitly decided for the single file, with the following group(s):", "submission.sections.upload.header.policy.default.withlist": "Lūdzu, ņemiet vērā, ka augšupielādētie faili kolekcijā {{collectionName}} būs pieejami papildus tam, kas ir skaidri noteikts par atsevišķu failu, ar šādām grupām:", - + // "submission.sections.upload.info": "Here you will find all the files currently in the item. You can update the file metadata and access conditions or upload additional files just dragging & dropping them everywhere in the page", "submission.sections.upload.info": "Šeit atradīsit visus failus, kas pašlaik atrodas materiālā. Varat atjaunināt failu metadatus un piekļuves nosacījumus vai augšupielādēt papildu failus, vienkārši ievelkot un atstājot tos visur lapā", - + // "submission.sections.upload.no-entry": "No", "submission.sections.upload.no-entry": "Nē", - + // "submission.sections.upload.no-file-uploaded": "No file uploaded yet.", "submission.sections.upload.no-file-uploaded": "Vēl nav augšupielādēts neviens fails.", - + // "submission.sections.upload.save-metadata": "Save metadata", "submission.sections.upload.save-metadata": "Saglabāt metadatus", - + // "submission.sections.upload.undo": "Cancel", "submission.sections.upload.undo": "Atcelt", - + // "submission.sections.upload.upload-failed": "Upload failed", "submission.sections.upload.upload-failed": "Augšupielāde neizdevās", - + // "submission.sections.upload.upload-successful": "Upload successful", "submission.sections.upload.upload-successful": "Augšupielāde veiksmīga", - - - + + + // "submission.submit.title": "Submission", "submission.submit.title": "Iesniegums", - - - + + + // "submission.workflow.generic.delete": "Delete", "submission.workflow.generic.delete": "Dzēst", - + // "submission.workflow.generic.delete-help": "If you would to discard this item, select \"Delete\". You will then be asked to confirm it.", "submission.workflow.generic.delete-help": "Ja vēlaties atmest šo materiālu izvēlieties \"Dzēst\". Pēc tam jums tiks lūgts to apstiprināt.", - + // "submission.workflow.generic.edit": "Edit", "submission.workflow.generic.edit": "Rediģēt", - + // "submission.workflow.generic.edit-help": "Select this option to change the item's metadata.", "submission.workflow.generic.edit-help": "Izvēlieties šo opciju, lai mainītu materiāla metadatus.", - + // "submission.workflow.generic.view": "View", "submission.workflow.generic.view": "Skatīt", - + // "submission.workflow.generic.view-help": "Select this option to view the item's metadata.", "submission.workflow.generic.view-help": "Izvēlieties šo opciju, lai skatītu materiāla metadatus.", - - - + + + // "submission.workflow.tasks.claimed.approve": "Approve", "submission.workflow.tasks.claimed.approve": "Apstiprināt", - + // "submission.workflow.tasks.claimed.approve_help": "If you have reviewed the item and it is suitable for inclusion in the collection, select \"Approve\".", "submission.workflow.tasks.claimed.approve_help": "Ja esat pārskatījis materiālu un tas ir piemērots iekļaušanai kolekcijā, atlasiet \"Apstiprināt\".", - + // "submission.workflow.tasks.claimed.edit": "Edit", "submission.workflow.tasks.claimed.edit": "Rediģēt", - + // "submission.workflow.tasks.claimed.edit_help": "Select this option to change the item's metadata.", "submission.workflow.tasks.claimed.edit_help": "Izvēlieties šo opciju, lai mainītu materiāla metadatus.", - + // "submission.workflow.tasks.claimed.reject.reason.info": "Please enter your reason for rejecting the submission into the box below, indicating whether the submitter may fix a problem and resubmit.", "submission.workflow.tasks.claimed.reject.reason.info": "Lūdzu, zemāk esošajā lodziņā ievadiet iesnieguma noraidīšanas iemeslu, norādot, vai iesniedzējs var novērst problēmu un atkārtoti iesniegt.", - + // "submission.workflow.tasks.claimed.reject.reason.placeholder": "Describe the reason of reject", "submission.workflow.tasks.claimed.reject.reason.placeholder": "Aprakstiet noraidījuma iemeslu", - + // "submission.workflow.tasks.claimed.reject.reason.submit": "Reject item", "submission.workflow.tasks.claimed.reject.reason.submit": "Noraidīt materiālu", - + // "submission.workflow.tasks.claimed.reject.reason.title": "Reason", "submission.workflow.tasks.claimed.reject.reason.title": "Iemesls", - + // "submission.workflow.tasks.claimed.reject.submit": "Reject", "submission.workflow.tasks.claimed.reject.submit": "Noraidīt", - + // "submission.workflow.tasks.claimed.reject_help": "If you have reviewed the item and found it is not suitable for inclusion in the collection, select \"Reject\". You will then be asked to enter a message indicating why the item is unsuitable, and whether the submitter should change something and resubmit.", "submission.workflow.tasks.claimed.reject_help": "Ja esat pārskatījis vienumu un secinājis, ka tas nav piemērots iekļaušanai kolekcijā, atlasiet \"Noraidīt\". Pēc tam jums tiks lūgts ievadīt ziņojumu, norādot, kāpēc materiāls nav piemērots, un vai iesniedzējam vajadzētu kaut ko mainīt un atkārtoti iesniegt.", - + // "submission.workflow.tasks.claimed.return": "Return to pool", "submission.workflow.tasks.claimed.return": "Atgriezt kopnē", - + // "submission.workflow.tasks.claimed.return_help": "Return the task to the pool so that another user may perform the task.", "submission.workflow.tasks.claimed.return_help": "Atgrieziet uzdevumu kopnē, lai uzdevumu varētu veikt cits lietotājs.", - - - + + + // "submission.workflow.tasks.generic.error": "Error occurred during operation...", "submission.workflow.tasks.generic.error": "Apstrādes laikā radās kļūda...", - + // "submission.workflow.tasks.generic.processing": "Processing...", "submission.workflow.tasks.generic.processing": "Apstrāde...", - + // "submission.workflow.tasks.generic.submitter": "Submitter", "submission.workflow.tasks.generic.submitter": "Iesniedzējs", - + // "submission.workflow.tasks.generic.success": "Operation successful", "submission.workflow.tasks.generic.success": "Darbībā veiksmīga", - - - + + + // "submission.workflow.tasks.pool.claim": "Claim", "submission.workflow.tasks.pool.claim": "Apstrādāt", - + // "submission.workflow.tasks.pool.claim_help": "Assign this task to yourself.", "submission.workflow.tasks.pool.claim_help": "Piešķirt šo uzdevumu sev.", - + // "submission.workflow.tasks.pool.hide-detail": "Hide detail", "submission.workflow.tasks.pool.hide-detail": "Paslēpt detaļas", - + // "submission.workflow.tasks.pool.show-detail": "Show detail", "submission.workflow.tasks.pool.show-detail": "Parādīt detaļas", - - - + + + // "title": "DSpace", "title": "DSpace", - - - + + + // "vocabulary-treeview.header": "Hierarchical tree view", // TODO New key - Add a translation "vocabulary-treeview.header": "Hierarchical tree view", - + // "vocabulary-treeview.load-more": "Load more", // TODO New key - Add a translation "vocabulary-treeview.load-more": "Load more", - + // "vocabulary-treeview.search.form.reset": "Reset", // TODO New key - Add a translation "vocabulary-treeview.search.form.reset": "Reset", - + // "vocabulary-treeview.search.form.search": "Search", // TODO New key - Add a translation "vocabulary-treeview.search.form.search": "Search", - + // "vocabulary-treeview.search.no-result": "There were no items to show", // TODO New key - Add a translation "vocabulary-treeview.search.no-result": "There were no items to show", - + // "vocabulary-treeview.tree.description.nsi": "The Norwegian Science Index", // TODO New key - Add a translation "vocabulary-treeview.tree.description.nsi": "The Norwegian Science Index", - + // "vocabulary-treeview.tree.description.srsc": "Research Subject Categories", // TODO New key - Add a translation "vocabulary-treeview.tree.description.srsc": "Research Subject Categories", - - - + + + // "uploader.browse": "browse", "uploader.browse": "pārlūkot", - + // "uploader.drag-message": "Drag & Drop your files here", "uploader.drag-message": "Velciet failu šeit", - + // "uploader.or": ", or ", // TODO Source message changed - Revise the translation "uploader.or": ", vai", - + // "uploader.processing": "Processing", "uploader.processing": "Datu apstrāde", - + // "uploader.queue-length": "Queue length", "uploader.queue-length": "Rindas garums", - + // "virtual-metadata.delete-item.info": "Select the types for which you want to save the virtual metadata as real metadata", "virtual-metadata.delete-item.info": "Izēlieties tipu, kam vēlaties saglabāt virtuālos metadatus kā reālus metadatus", - + // "virtual-metadata.delete-item.modal-head": "The virtual metadata of this relation", "virtual-metadata.delete-item.modal-head": "Šīs saiknes virtuālie metadati", - + // "virtual-metadata.delete-relationship.modal-head": "Select the items for which you want to save the virtual metadata as real metadata", "virtual-metadata.delete-relationship.modal-head": "Izēlieties materiālus, kam vēlaties saglabāt virtuālos metadatus kā reālus metadatus", - - - + + + // "workflowAdmin.search.results.head": "Administer Workflow", - "workflowAdmin.search.results.head": "Administrēt darba plūsmu" - - - + "workflowAdmin.search.results.head": "Administrēt darba plūsmu", + + + // "workflow-item.delete.notification.success.title": "Deleted", // TODO New key - Add a translation "workflow-item.delete.notification.success.title": "Deleted", - + // "workflow-item.delete.notification.success.content": "This workflow item was successfully deleted", // TODO New key - Add a translation "workflow-item.delete.notification.success.content": "This workflow item was successfully deleted", - + // "workflow-item.delete.notification.error.title": "Something went wrong", // TODO New key - Add a translation "workflow-item.delete.notification.error.title": "Something went wrong", - + // "workflow-item.delete.notification.error.content": "The workflow item could not be deleted", // TODO New key - Add a translation "workflow-item.delete.notification.error.content": "The workflow item could not be deleted", - + // "workflow-item.delete.title": "Delete workflow item", // TODO New key - Add a translation "workflow-item.delete.title": "Delete workflow item", - + // "workflow-item.delete.header": "Delete workflow item", // TODO New key - Add a translation "workflow-item.delete.header": "Delete workflow item", - + // "workflow-item.delete.button.cancel": "Cancel", // TODO New key - Add a translation "workflow-item.delete.button.cancel": "Cancel", - + // "workflow-item.delete.button.confirm": "Delete", // TODO New key - Add a translation "workflow-item.delete.button.confirm": "Delete", - - + + // "workflow-item.send-back.notification.success.title": "Sent back to submitter", // TODO New key - Add a translation "workflow-item.send-back.notification.success.title": "Sent back to submitter", - + // "workflow-item.send-back.notification.success.content": "This workflow item was successfully sent back to the submitter", // TODO New key - Add a translation "workflow-item.send-back.notification.success.content": "This workflow item was successfully sent back to the submitter", - + // "workflow-item.send-back.notification.error.title": "Something went wrong", // TODO New key - Add a translation "workflow-item.send-back.notification.error.title": "Something went wrong", - + // "workflow-item.send-back.notification.error.content": "The workflow item could not be sent back to the submitter", // TODO New key - Add a translation "workflow-item.send-back.notification.error.content": "The workflow item could not be sent back to the submitter", - + // "workflow-item.send-back.title": "Send workflow item back to submitter", // TODO New key - Add a translation "workflow-item.send-back.title": "Send workflow item back to submitter", - + // "workflow-item.send-back.header": "Send workflow item back to submitter", // TODO New key - Add a translation "workflow-item.send-back.header": "Send workflow item back to submitter", - + // "workflow-item.send-back.button.cancel": "Cancel", // TODO New key - Add a translation "workflow-item.send-back.button.cancel": "Cancel", - + // "workflow-item.send-back.button.confirm": "Send back" // TODO New key - Add a translation "workflow-item.send-back.button.confirm": "Send back" - -} \ No newline at end of file + +} diff --git a/src/assets/images/orcid.logo.icon.svg b/src/assets/images/orcid.logo.icon.svg new file mode 100644 index 0000000000..8aec5959e5 --- /dev/null +++ b/src/assets/images/orcid.logo.icon.svg @@ -0,0 +1,21 @@ + + + + Orcid logo + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/config/actuators.config.ts b/src/config/actuators.config.ts new file mode 100644 index 0000000000..8f59a13c98 --- /dev/null +++ b/src/config/actuators.config.ts @@ -0,0 +1,11 @@ +import { Config } from './config.interface'; + +/** + * Config that determines the spring Actuators options + */ +export class ActuatorsConfig implements Config { + /** + * The endpoint path + */ + public endpointPath: string; +} diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 04beb846cd..e6758cdfcd 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -7,7 +7,7 @@ import { INotificationBoardOptions } from './notifications-config.interfaces'; import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; import { LangConfig } from './lang-config.interface'; -import { ItemPageConfig } from './item-page-config.interface'; +import { ItemConfig } from './item-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { ThemeConfig } from './theme.model'; import { AuthConfig } from './auth-config.interfaces'; @@ -16,6 +16,7 @@ import { MediaViewerConfig } from './media-viewer-config.interface'; import { BrowseByConfig } from './browse-by-config.interface'; import {SuggestionConfig} from './layout-config.interfaces'; import { BundleConfig } from './bundle-config.interface'; +import { ActuatorsConfig } from './actuators.config'; interface AppConfig extends Config { ui: UIServerConfig; @@ -30,12 +31,13 @@ interface AppConfig extends Config { defaultLanguage: string; languages: LangConfig[]; browseBy: BrowseByConfig; - item: ItemPageConfig; + item: ItemConfig; collection: CollectionPageConfig; themes: ThemeConfig[]; mediaViewer: MediaViewerConfig; suggestion: SuggestionConfig[]; bundle: BundleConfig; + actuators: ActuatorsConfig } const APP_CONFIG = new InjectionToken('APP_CONFIG'); diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 0e4ff7dd01..607009bf2e 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -6,7 +6,7 @@ import { BrowseByConfig } from './browse-by-config.interface'; import { CacheConfig } from './cache-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { FormConfig } from './form-config.interfaces'; -import { ItemPageConfig } from './item-page-config.interface'; +import { ItemConfig } from './item-config.interface'; import { LangConfig } from './lang-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; @@ -16,6 +16,7 @@ import { ThemeConfig } from './theme.model'; import { UIServerConfig } from './ui-server-config.interface'; import {SuggestionConfig} from './layout-config.interfaces'; import { BundleConfig } from './bundle-config.interface'; +import { ActuatorsConfig } from './actuators.config'; export class DefaultAppConfig implements AppConfig { production = false; @@ -49,6 +50,10 @@ export class DefaultAppConfig implements AppConfig { nameSpace: '/', }; + actuators: ActuatorsConfig = { + endpointPath: '/actuator/health' + }; + // Caching settings cache: CacheConfig = { // NOTE: how long should objects be cached for by default @@ -112,6 +117,9 @@ export class DefaultAppConfig implements AppConfig { */ timer: 0 }, + typeBind: { + field: 'dc.type' + }, icons: { metadata: [ /** @@ -199,11 +207,13 @@ export class DefaultAppConfig implements AppConfig { defaultLowerLimit: 1900 }; - // Item Page Config - item: ItemPageConfig = { + // Item Config + item: ItemConfig = { edit: { undoTimeout: 10000 // 10 seconds - } + }, + // Show the item access status label in items lists + showAccessStatuses: false }; // Collection Page Config diff --git a/src/config/item-config.interface.ts b/src/config/item-config.interface.ts new file mode 100644 index 0000000000..f842c37c05 --- /dev/null +++ b/src/config/item-config.interface.ts @@ -0,0 +1,9 @@ +import { Config } from './config.interface'; + +export interface ItemConfig extends Config { + edit: { + undoTimeout: number; + }; + // This is used to show the access status label of items in results lists + showAccessStatuses: boolean; +} diff --git a/src/config/item-page-config.interface.ts b/src/config/item-page-config.interface.ts deleted file mode 100644 index 2b05e28715..0000000000 --- a/src/config/item-page-config.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Config } from './config.interface'; - -export interface ItemPageConfig extends Config { - edit: { - undoTimeout: number; - }; -} diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts index ce275b9bf8..a63af45e38 100644 --- a/src/config/submission-config.interface.ts +++ b/src/config/submission-config.interface.ts @@ -5,6 +5,10 @@ interface AutosaveConfig extends Config { timer: number; } +interface TypeBindConfig extends Config { + field: string; +} + interface IconsConfig extends Config { metadata: MetadataIconConfig[]; authority: { @@ -24,5 +28,6 @@ export interface ConfidenceIconConfig extends Config { export interface SubmissionConfig extends Config { autosave: AutosaveConfig; + typeBind: TypeBindConfig; icons: IconsConfig; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 8c0fe0289f..11deac9f29 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -39,6 +39,10 @@ export const environment: BuildConfig = { baseUrl: 'https://rest.com/api' }, + actuators: { + endpointPath: '/actuator/health' + }, + // Caching settings cache: { // NOTE: how long should objects be cached for by default @@ -101,6 +105,9 @@ export const environment: BuildConfig = { // NOTE: every how many minutes submission is saved automatically timer: 5 }, + typeBind: { + field: 'dc.type' + }, icons: { metadata: [ { @@ -197,7 +204,9 @@ export const environment: BuildConfig = { item: { edit: { undoTimeout: 10000 // 10 seconds - } + }, + // Show the item access status label in items lists + showAccessStatuses: false }, collection: { edit: { diff --git a/src/index.csr.html b/src/index.csr.html index d23cb2cae3..b1ef4343b1 100644 --- a/src/index.csr.html +++ b/src/index.csr.html @@ -13,6 +13,6 @@ - + diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 88a59eb157..dc3be0de30 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -2,11 +2,10 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { BrowserModule, 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'; @@ -18,7 +17,7 @@ import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.ser 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'; @@ -42,8 +41,8 @@ import { environment } from '../../environments/environment'; 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,13 +58,6 @@ 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, @@ -74,7 +66,7 @@ export function getRequest(transferState: TransferState): any { loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [HttpClient] + deps: [TransferState, HttpClient] } }), AppModule @@ -92,9 +84,11 @@ export function getRequest(transferState: TransferState): any { // extend environment with app config for browser extendEnvironmentWithAppConfig(environment, appConfig); } - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; + return () => + dspaceTransferState.transfer().then((b: boolean) => { + correlationIdService.initCorrelationId(); + return b; + }); }, deps: [TransferState, DSpaceTransferState, CorrelationIdService], multi: true diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index f5b2c4e27b..236b7bc5a0 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -3,19 +3,18 @@ import { APP_INITIALIZER, 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 { 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'; @@ -37,8 +36,8 @@ import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; 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,16 +46,13 @@ export function createTranslateLoader() { BrowserModule.withServerTransition({ appId: 'dspace-angular' }), - RouterModule.forRoot([], { - useHash: false - }), NoopAnimationsModule, DSpaceServerTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useFactory: (createTranslateLoader), - deps: [] + deps: [TransferState] } }), AppModule, diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts b/src/modules/transfer-state/dspace-browser-transfer-state.service.ts index ae3306c3fb..512d6aeb71 100644 --- a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts +++ b/src/modules/transfer-state/dspace-browser-transfer-state.service.ts @@ -1,12 +1,19 @@ import { Injectable } from '@angular/core'; +import { coreSelector } from 'src/app/core/core.selectors'; import { StoreAction, StoreActionTypes } from '../../app/store.actions'; import { DSpaceTransferState } from './dspace-transfer-state.service'; +import { find, map } from 'rxjs/operators'; +import { isNotEmpty } from '../../app/shared/empty.util'; @Injectable() export class DSpaceBrowserTransferState extends DSpaceTransferState { - transfer() { + transfer(): Promise { const state = this.transferState.get(DSpaceTransferState.NGRX_STATE, null); this.transferState.remove(DSpaceTransferState.NGRX_STATE); this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); + return this.store.select(coreSelector).pipe( + find((core: any) => isNotEmpty(core)), + map(() => true) + ).toPromise(); } } diff --git a/src/modules/transfer-state/dspace-server-transfer-state.service.ts b/src/modules/transfer-state/dspace-server-transfer-state.service.ts index ac8c817d84..96b1e4be38 100644 --- a/src/modules/transfer-state/dspace-server-transfer-state.service.ts +++ b/src/modules/transfer-state/dspace-server-transfer-state.service.ts @@ -5,7 +5,7 @@ import { DSpaceTransferState } from './dspace-transfer-state.service'; @Injectable() export class DSpaceServerTransferState extends DSpaceTransferState { - transfer() { + transfer(): Promise { this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => { let state; this.store.pipe(take(1)).subscribe((saveState: any) => { @@ -14,5 +14,7 @@ export class DSpaceServerTransferState extends DSpaceTransferState { return state; }); + + return new Promise(() => true); } } diff --git a/src/modules/transfer-state/dspace-transfer-state.service.ts b/src/modules/transfer-state/dspace-transfer-state.service.ts index 05b1109f17..32761866fb 100644 --- a/src/modules/transfer-state/dspace-transfer-state.service.ts +++ b/src/modules/transfer-state/dspace-transfer-state.service.ts @@ -14,5 +14,5 @@ export abstract class DSpaceTransferState { ) { } - abstract transfer(): void; + abstract transfer(): Promise; } 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 657be1012d..0000000000 --- a/src/ngx-translate-loaders/translate-json5-universal.loader.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TranslateLoader } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import * as fs from 'fs'; - -const JSON5 = require('json5').default; - -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/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 4c631a294a..20c0b3a679 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -6,7 +6,9 @@ $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; diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index 9c154c7a22..89d1d76e9a 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -115,3 +115,26 @@ ngb-modal-backdrop { .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/base-theme.scss b/src/styles/base-theme.scss index bde50bcfd7..a88057fec1 100644 --- a/src/styles/base-theme.scss +++ b/src/styles/base-theme.scss @@ -5,3 +5,4 @@ @import './bootstrap_variables_mapping.scss'; @import './_truncatable-part.component.scss'; @import './_global-styles.scss'; +@import '../../node_modules/ngx-ui-switch/ui-switch.component.scss'; diff --git a/src/test.ts b/src/test.ts index dfafd98807..477195418b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -7,6 +7,7 @@ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; declare const require: any; @@ -17,9 +18,11 @@ getTestBed().initTestEnvironment( { teardown: { destroyAfterEach: false } } ); -// If store is mocked, reset state after each test (see https://ngrx.io/guide/migration/v13) 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. diff --git a/src/themes/custom/eager-theme.module.ts b/src/themes/custom/eager-theme.module.ts new file mode 100644 index 0000000000..fdbdba1038 --- /dev/null +++ b/src/themes/custom/eager-theme.module.ts @@ -0,0 +1,58 @@ +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'; + +/** + * 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 = [ + PublicationComponent, +]; + +const DECLARATIONS = [ + ...ENTRY_COMPONENTS, + HomeNewsComponent, + HeaderComponent, + HeaderNavbarWrapperComponent, + NavbarComponent, + FooterComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + SearchModule, + FormsModule, + RootModule, + NavbarModule, + ItemPageModule, + ], + 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 91% rename from src/themes/custom/theme.module.ts rename to src/themes/custom/lazy-theme.module.ts index dea61daf0a..874dff90d3 100644 --- a/src/themes/custom/theme.module.ts +++ b/src/themes/custom/lazy-theme.module.ts @@ -28,20 +28,28 @@ 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'; @@ -49,14 +57,10 @@ 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 { 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,32 +70,21 @@ 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 { MyDSpacePageComponent } from './app/my-dspace-page/my-dspace-page.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 { 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'; + const DECLARATIONS = [ FileSectionComponent, HomePageComponent, - HomeNewsComponent, RootComponent, - PublicationComponent, BrowseBySwitcherComponent, CommunityListPageComponent, SearchPageComponent, @@ -122,22 +115,18 @@ const DECLARATIONS = [ SubmissionSubmitComponent, WorkflowItemDeleteComponent, WorkflowItemSendBackComponent, - FooterComponent, - HeaderComponent, - NavbarComponent, - HeaderNavbarWrapperComponent, BreadcrumbsComponent, FeedbackComponent, - CommunityListComponent + CommunityListComponent, ]; @NgModule({ imports: [ - AccessControlModule, AdminRegistriesModule, AdminSearchModule, AdminWorkflowModuleModule, AppModule, + RootModule, BitstreamFormatsModule, BrowseByModule, CollectionFormModule, @@ -178,9 +167,9 @@ const DECLARATIONS = [ SearchModule, FormsModule, ResourcePoliciesModule, - ComcolModule + ComcolModule, ], - declarations: DECLARATIONS + declarations: DECLARATIONS, }) /** @@ -190,5 +179,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..ad2c0de24e 100644 --- a/src/themes/custom/styles/theme.scss +++ b/src/themes/custom/styles/theme.scss @@ -11,3 +11,4 @@ @import '../../../styles/bootstrap_variables_mapping.scss'; @import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss'; +@import '../../../../node_modules/ngx-ui-switch/ui-switch.component.scss'; diff --git a/src/themes/dspace/app/home-page/home-news/home-news.component.html b/src/themes/dspace/app/home-page/home-news/home-news.component.html index 92ce1ba020..ef576ed99c 100644 --- a/src/themes/dspace/app/home-page/home-news/home-news.component.html +++ b/src/themes/dspace/app/home-page/home-news/home-news.component.html @@ -1,4 +1,4 @@ -
+
@@ -30,5 +30,10 @@
+ + + + + Photo by @inspiredimages
diff --git a/src/themes/dspace/app/home-page/home-news/home-news.component.scss b/src/themes/dspace/app/home-page/home-news/home-news.component.scss index 5e89f6b62f..93ec1763f3 100644 --- a/src/themes/dspace/app/home-page/home-news/home-news.component.scss +++ b/src/themes/dspace/app/home-page/home-news/home-news.component.scss @@ -2,12 +2,21 @@ display: block; margin-top: calc(var(--ds-content-spacing) * -1); - div.background-image { + div.background-image-container { color: white; - background-color: var(--bs-info); position: relative; - background-image: url('/assets/dspace/images/banner.jpg'); - background-size: cover; + + .background-image > img { + background-color: var(--bs-info); + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: top; + } .container { position: relative; diff --git a/src/themes/dspace/assets/images/banner-half.jpg b/src/themes/dspace/assets/images/banner-half.jpg new file mode 100644 index 0000000000..31610cc350 Binary files /dev/null and b/src/themes/dspace/assets/images/banner-half.jpg differ diff --git a/src/themes/dspace/assets/images/banner-half.webp b/src/themes/dspace/assets/images/banner-half.webp new file mode 100644 index 0000000000..f11cfb7859 Binary files /dev/null and b/src/themes/dspace/assets/images/banner-half.webp differ diff --git a/src/themes/dspace/assets/images/banner-tall.jpg b/src/themes/dspace/assets/images/banner-tall.jpg new file mode 100644 index 0000000000..d310311296 Binary files /dev/null and b/src/themes/dspace/assets/images/banner-tall.jpg differ diff --git a/src/themes/dspace/assets/images/banner-tall.webp b/src/themes/dspace/assets/images/banner-tall.webp new file mode 100644 index 0000000000..a4ec97f2bc Binary files /dev/null and b/src/themes/dspace/assets/images/banner-tall.webp differ diff --git a/src/themes/dspace/assets/images/banner.webp b/src/themes/dspace/assets/images/banner.webp new file mode 100644 index 0000000000..7745766f05 Binary files /dev/null and b/src/themes/dspace/assets/images/banner.webp differ diff --git a/src/themes/dspace/eager-theme.module.ts b/src/themes/dspace/eager-theme.module.ts new file mode 100644 index 0000000000..5dd114cd72 --- /dev/null +++ b/src/themes/dspace/eager-theme.module.ts @@ -0,0 +1,52 @@ +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'; + +/** + * 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 = [ +]; + +const DECLARATIONS = [ + ...ENTRY_COMPONENTS, + HomeNewsComponent, + HeaderComponent, + HeaderNavbarWrapperComponent, + NavbarComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + SearchModule, + FormsModule, + RootModule, + NavbarModule, + ], + 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/dspace/entry-components.ts b/src/themes/dspace/entry-components.ts deleted file mode 100644 index 2386ecb130..0000000000 --- a/src/themes/dspace/entry-components.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ENTRY_COMPONENTS = [ -]; diff --git a/src/themes/dspace/theme.module.ts b/src/themes/dspace/lazy-theme.module.ts similarity index 70% rename from src/themes/dspace/theme.module.ts rename to src/themes/dspace/lazy-theme.module.ts index 2a774eb9c8..a4e8027a15 100644 --- a/src/themes/dspace/theme.module.ts +++ b/src/themes/dspace/lazy-theme.module.ts @@ -2,10 +2,16 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AdminRegistriesModule } from '../../app/admin/admin-registries/admin-registries.module'; import { AdminSearchModule } from '../../app/admin/admin-search-page/admin-search.module'; -import { AdminWorkflowModuleModule } from '../../app/admin/admin-workflow-page/admin-workflow.module'; -import { BitstreamFormatsModule } from '../../app/admin/admin-registries/bitstream-formats/bitstream-formats.module'; +import { + AdminWorkflowModuleModule +} from '../../app/admin/admin-workflow-page/admin-workflow.module'; +import { + BitstreamFormatsModule +} from '../../app/admin/admin-registries/bitstream-formats/bitstream-formats.module'; import { BrowseByModule } from '../../app/browse-by/browse-by.module'; -import { CollectionFormModule } from '../../app/collection-page/collection-form/collection-form.module'; +import { + CollectionFormModule +} from '../../app/collection-page/collection-form/collection-form.module'; import { CommunityFormModule } from '../../app/community-page/community-form/community-form.module'; import { CoreModule } from '../../app/core/core.module'; import { DragDropModule } from '@angular/cdk/drag-drop'; @@ -13,14 +19,18 @@ import { EditItemPageModule } from '../../app/item-page/edit-item-page/edit-item import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { IdlePreloadModule } from 'angular-idle-preload'; -import { JournalEntitiesModule } from '../../app/entity-groups/journal-entities/journal-entities.module'; +import { + JournalEntitiesModule +} from '../../app/entity-groups/journal-entities/journal-entities.module'; import { MyDspaceSearchModule } from '../../app/my-dspace-page/my-dspace-search.module'; import { MenuModule } from '../../app/shared/menu/menu.module'; import { NavbarModule } from '../../app/navbar/navbar.module'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { ProfilePageModule } from '../../app/profile-page/profile-page.module'; import { RegisterEmailFormModule } from '../../app/register-email-form/register-email-form.module'; -import { ResearchEntitiesModule } from '../../app/entity-groups/research-entities/research-entities.module'; +import { + ResearchEntitiesModule +} from '../../app/entity-groups/research-entities/research-entities.module'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { SearchPageModule } from '../../app/search-page/search-page.module'; import { SharedModule } from '../../app/shared/shared.module'; @@ -28,7 +38,6 @@ 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 { HomePageModule } from '../../app/home-page/home-page.module'; import { AppModule } from '../../app/app.module'; import { ItemPageModule } from '../../app/item-page/item-page.module'; @@ -40,18 +49,14 @@ import { CommunityPageModule } from '../../app/community-page/community-page.mod 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 { 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 { ResourcePoliciesModule } from '../../app/shared/resource-policies/resource-policies.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'; const DECLARATIONS = [ - HomeNewsComponent, - HeaderComponent, - HeaderNavbarWrapperComponent, - NavbarComponent ]; @NgModule({ @@ -60,6 +65,7 @@ const DECLARATIONS = [ AdminSearchModule, AdminWorkflowModuleModule, AppModule, + RootModule, BitstreamFormatsModule, BrowseByModule, CollectionFormModule, @@ -100,17 +106,17 @@ const DECLARATIONS = [ SearchModule, FormsModule, ResourcePoliciesModule, - ComcolModule + ComcolModule, ], - declarations: DECLARATIONS + declarations: DECLARATIONS, }) - /** - * This module serves as an index for all the components in this theme. - * It should import all other modules, so the compiler knows where to find any components referenced - * from a component in this theme - * 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 { +/** + * This module serves as an index for all the components in this theme. + * It should import all other modules, so the compiler knows where to find any components referenced + * from a component in this theme + * 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 LazyThemeModule { } diff --git a/src/themes/dspace/styles/theme.scss b/src/themes/dspace/styles/theme.scss index 35810b15a6..ad2c0de24e 100644 --- a/src/themes/dspace/styles/theme.scss +++ b/src/themes/dspace/styles/theme.scss @@ -11,3 +11,4 @@ @import '../../../styles/bootstrap_variables_mapping.scss'; @import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss'; +@import '../../../../node_modules/ngx-ui-switch/ui-switch.component.scss'; diff --git a/src/themes/eager-themes.module.ts b/src/themes/eager-themes.module.ts new file mode 100644 index 0000000000..4a46595f35 --- /dev/null +++ b/src/themes/eager-themes.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module'; +// import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module'; + +/** + * This module bundles the eager theme modules for all available themes. + * Eager modules contain components that are present on every page (to speed up initial loading) + * and entry components (to ensure their decorators get picked up). + * + * Themes that aren't in use should not be imported here so they don't take up unnecessary space in the main bundle. + */ +@NgModule({ + imports: [ + DSpaceEagerThemeModule, + // CustomEagerThemeModule, + ], +}) +export class EagerThemesModule { +} diff --git a/src/themes/themed-entry-component.module.ts b/src/themes/themed-entry-component.module.ts deleted file mode 100644 index 41cdf62269..0000000000 --- a/src/themes/themed-entry-component.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ENTRY_COMPONENTS as CUSTOM } from './custom/entry-components'; - -const ENTRY_COMPONENTS = [ - ...CUSTOM, -]; - - -/** - * This module only serves to ensure themed entry components are discoverable. It's kept separate - * from the theme modules to ensure only the minimal number of theme components is loaded ahead of - * time - */ -@NgModule() -export class ThemedEntryComponentModule { - static withEntryComponents() { - return { - ngModule: ThemedEntryComponentModule, - providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) - }; - } - -} diff --git a/webpack/webpack.browser.ts b/webpack/webpack.browser.ts index 5ed4f27e41..2c33d91afd 100644 --- a/webpack/webpack.browser.ts +++ b/webpack/webpack.browser.ts @@ -3,12 +3,37 @@ import { join } from 'path'; import { buildAppConfig } from '../src/config/config.server'; import { commonExports } from './webpack.common'; +const CompressionPlugin = require('compression-webpack-plugin'); +const zlib = require('zlib'); + module.exports = Object.assign({}, commonExports, { target: 'web', + plugins: [ + ...commonExports.plugins, + new CompressionPlugin({ + filename: '[path][base].gz', + algorithm: 'gzip', + test: /\.(js|css|html|svg|json5)$/, + threshold: 10240, + minRatio: 0.8, + }), + new CompressionPlugin({ + filename: '[path][base].br', + algorithm: 'brotliCompress', + test: /\.(js|css|html|svg|json5)$/, + compressionOptions: { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, + }, + }, + threshold: 10240, + minRatio: 0.8, + }), + ], devServer: { setupMiddlewares(middlewares, server) { buildAppConfig(join(process.cwd(), 'src/assets/config.json')); return middlewares; } - } + } }); diff --git a/yarn.lock b/yarn.lock index c06853e625..f9352e8f05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1670,17 +1670,17 @@ dependencies: tslib "^2.3.0" -"@ng-dynamic-forms/core@^14.0.1": - version "14.0.1" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-14.0.1.tgz#e5815a7f67b4e23a5c726afd137b3e27afe09ab9" - integrity sha512-Pys4H0lSk2Ae8y80mRD4yZMTu+80DIOmf4B2L9fK2q/zYyxVSexu0DynDR8XApArXYU78EPsWnEwgNSWwX6RKw== +"@ng-dynamic-forms/core@^15.0.0": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-15.0.0.tgz#674a88c253aa100b30144bf7ebf518e24b72f553" + integrity sha512-JJ0w8WdOA+wsHyt/hwitGhv/e1j95/TlRS82vvZetP/Ip3kjvD/Ge8jbg4bEssIAXZjfBqS/Gy00Hxo4h57DgQ== dependencies: tslib "^2.0.0" -"@ng-dynamic-forms/ui-ng-bootstrap@^14.0.1": - version "14.0.1" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-14.0.1.tgz#10f271b85eceadad02f616f752cf9806eb085106" - integrity sha512-Xf56kZBwM0vsRgEKcZvh8SsypCWcVTKeyq9id68+jQzH9/bQ+qriLBF35zDHrS9vJWmSufa5xqqRx/ycxhfpLw== +"@ng-dynamic-forms/ui-ng-bootstrap@^15.0.0": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-15.0.0.tgz#0ab5614bc2efccc4cddbb384865b66d4740bcd3d" + integrity sha512-b/+tOJxtDRMzoFA7KLA8JRxbAnXd8d8072/P6C+2xOMaG0Ttc1UUiNQOZ5w82y78nr0bZ63oFHSR0xzSVtMXnA== dependencies: tslib "^2.0.0" @@ -2920,12 +2920,12 @@ angular-idle-preload@3.0.0: resolved "https://registry.yarnpkg.com/angular-idle-preload/-/angular-idle-preload-3.0.0.tgz#decace34d9fac1cb00000727a6dc5caafdb84e4d" integrity sha512-W3P2m2B6MHdt1DVunH6H3VWkAZrG3ZwxGcPjedVvIyRhg/LmMtILoizHSxTXw3fsKIEdAPwGObXGpML9WD1jJA== -angulartics2@^10.0.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/angulartics2/-/angulartics2-10.1.0.tgz#2988f95f25cf6a8dd630d63ea604eb6643e076c3" - integrity sha512-MnwQxRXJkfbBF7417Cs7L/SIuTRNWHCOBnGolZXHFz5ogw1e51KdCKUaUkfgBogR7JpXP279FU9UDkzerIS3xw== +angulartics2@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/angulartics2/-/angulartics2-12.0.0.tgz#d9440ff98d133ae02d97b991a32a711a5b88559f" + integrity sha512-hNjvOp/IvKD00Ix3zRGfGJUwwOhSM5RFhvM/iSBH7dvJKavCBWbI464PWshjXfRBbruangPUbJGhSLnoENNtmg== dependencies: - tslib "^2.0.0" + tslib "^2.3.0" ansi-align@^3.0.0: version "3.0.1" @@ -3155,11 +3155,6 @@ async-each-series@0.1.1: resolved "https://registry.yarnpkg.com/async-each-series/-/async-each-series-0.1.1.tgz#7617c1917401fd8ca4a28aadce3dbae98afeb432" integrity sha1-dhfBkXQB/Yykooqtzj266Yr+tDI= -async@0.9.x: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= - async@1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -3172,7 +3167,7 @@ async@^2.6.2: dependencies: lodash "^4.17.14" -async@^3.2.0: +async@^3.2.0, async@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== @@ -3226,6 +3221,14 @@ axios@0.21.4: dependencies: follow-redirects "^1.14.0" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -3460,6 +3463,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" @@ -4111,17 +4121,13 @@ compressible@~2.0.16: dependencies: mime-db ">= 1.43.0 < 2" -compression-webpack-plugin@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz#9f510172a7b5fae5aad3b670652e8bd7997aeeca" - integrity sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug== +compression-webpack-plugin@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-9.2.0.tgz#57fd539d17c5907eebdeb4e83dcfe2d7eceb9ef6" + integrity sha512-R/Oi+2+UHotGfu72fJiRoVpuRifZT0tTC6UqFD/DUo+mv8dbOow9rVOuTvDv5nPPm3GZhHL/fKkwxwIHnJ8Nyw== dependencies: - cacache "^13.0.1" - find-cache-dir "^3.0.0" - neo-async "^2.5.0" - schema-utils "^2.6.1" - serialize-javascript "^2.1.2" - webpack-sources "^1.0.1" + schema-utils "^4.0.0" + serialize-javascript "^6.0.0" compression@^1.7.4: version "1.7.4" @@ -4139,7 +4145,7 @@ compression@^1.7.4: concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== configstore@^5.0.1: version "5.0.1" @@ -4613,10 +4619,10 @@ custom-event@~1.0.0: resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU= -cypress-axe@^0.13.0: - version "0.13.0" - resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-0.13.0.tgz#3234e1a79a27701f2451fcf2f333eb74204c7966" - integrity sha512-fCIy7RiDCm7t30U3C99gGwQrUO307EYE1QqXNaf9ToK4DVqW8y5on+0a/kUHMrHdlls2rENF6TN9ZPpPpwLrnw== +cypress-axe@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-0.14.0.tgz#5f5e70fb36b8cb3ba73a8ba01e9262ff1268d5e2" + integrity sha512-7Rdjnko0MjggCmndc1wECAkvQBIhuy+DRtjF7bd5YPZRFvubfMNvrxfqD8PWQmxm7MZE0ffS4Xr43V6ZmvLopg== cypress@9.5.1: version "9.5.1" @@ -4916,6 +4922,11 @@ dependency-graph@^0.11.0: resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + destroy@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" @@ -5138,11 +5149,11 @@ ee-first@1.1.1: integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= ejs@^3.1.5: - version "3.1.6" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a" - integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw== + version "3.1.8" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b" + integrity sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ== dependencies: - jake "^10.6.1" + jake "^10.8.5" electron-to-chromium@^1.4.71: version "1.4.75" @@ -5819,6 +5830,13 @@ express-rate-limit@^5.1.3: resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.5.1.tgz#110c23f6a65dfa96ab468eda95e71697bc6987a2" integrity sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg== +express-static-gzip@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/express-static-gzip/-/express-static-gzip-2.1.5.tgz#e45b512ef5596a068c45f729d7e0cc0b429b08b4" + integrity sha512-bgiQ1fY7ltuUrSzg0WoN7ycoAd7r2VEw7durn/3k0jCMUC5wydF0K36ouIuJPE+MNDwK5uoSaVgIBVNemwxWgw== + dependencies: + serve-static "^1.14.1" + express@^4.17.1: version "4.17.3" resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" @@ -6017,11 +6035,11 @@ file-saver@^2.0.5: integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== filelist@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b" - integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ== + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== dependencies: - minimatch "^3.0.4" + minimatch "^5.0.1" filesize@^6.1.0: version "6.4.0" @@ -6071,7 +6089,7 @@ finalhandler@1.1.2, finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -find-cache-dir@^3.0.0, find-cache-dir@^3.3.1: +find-cache-dir@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== @@ -6108,7 +6126,7 @@ flatted@^3.1.0, flatted@^3.2.5: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== -follow-redirects@^1.0.0, follow-redirects@^1.14.0: +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.9: version "1.14.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== @@ -7516,12 +7534,12 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jake@^10.6.1: - version "10.8.4" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.4.tgz#f6a8b7bf90c6306f768aa82bb7b98bf4ca15e84a" - integrity sha512-MtWeTkl1qGsWUtbl/Jsca/8xSoK3x0UmS82sNbjqxxG/de/M/3b1DntdjHgPMC50enlTNwXOCRqPXLLt5cCfZA== +jake@^10.8.5: + version "10.8.5" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" + integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== dependencies: - async "0.9.x" + async "^3.2.3" chalk "^4.0.2" filelist "^1.0.1" minimatch "^3.0.4" @@ -7536,12 +7554,12 @@ jasmine-core@~2.8.0: resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.8.0.tgz#bcc979ae1f9fd05701e45e52e65d3a5d63f1a24e" integrity sha1-vMl5rh+f0FcB5F5S5l06XWPxok4= -jasmine-marbles@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/jasmine-marbles/-/jasmine-marbles-0.6.0.tgz#f78dc1a3bc452976de10ee8b47c73d616532a954" - integrity sha512-1uzgjEesEeCb+r+v46qn5x326TiGqk5SUZa+A3O+XnMCjG/pGcUOhL9Xsg5L7gLC6RFHyWGTkB5fei4rcvIOiQ== +jasmine-marbles@0.9.2: + version "0.9.2" + resolved "https://registry.yarnpkg.com/jasmine-marbles/-/jasmine-marbles-0.9.2.tgz#5adfee5f72c7f24270687fa64a6e8a8613ffa841" + integrity sha512-T7RjG4fRsdiGGzbQZ6Kj39qYt6O1/KIcR4FkUNsD3DUGkd/AzpwzN+xtk0DXlLWEz5BaVdK1SzMgQDVw879c4Q== dependencies: - lodash "^4.5.0" + lodash "^4.17.20" jasmine-spec-reporter@~5.0.0: version "5.0.2" @@ -8211,7 +8229,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.5.0, lodash@^4.7.0: +lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -8554,10 +8572,17 @@ minimatch@^3.0.2, minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" + integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" @@ -8716,10 +8741,10 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -moment@^2.29.1: - version "2.29.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== +moment@^2.29.2: + version "2.29.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== morgan@^1.10.0: version "1.10.0" @@ -8823,7 +8848,7 @@ negotiator@0.6.3, negotiator@^0.6.2, negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -neo-async@^2.5.0, neo-async@^2.6.2: +neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -8853,12 +8878,12 @@ ngx-infinite-scroll@^10.0.1: "@scarf/scarf" "^1.1.0" opencollective-postinstall "^2.0.2" -ngx-mask@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/ngx-mask/-/ngx-mask-12.0.0.tgz#8eb363cc609ab71b687bbe6f87497c461ca120b1" - integrity sha512-q4vUjhjJfg4faRud/tUdCTOs3JA6B+rBB2OPZ2xBZy4LNTRKGfUK683LrDCitMVBezjEAVrkQdUT1I4C7LXBZQ== +ngx-mask@^13.1.7: + version "13.1.7" + resolved "https://registry.yarnpkg.com/ngx-mask/-/ngx-mask-13.1.7.tgz#9ef40354a83484aaf77aff74742cd0f43b4a65cd" + integrity sha512-zwGSEGt+WRlb31qMd92K25MCNUhfI2XKOMv+m5NypkZ+stONdBxAXjp8wA/1MJ46uYF5UYLmKPdkXloZBtOXQQ== dependencies: - tslib "^2.1.0" + tslib "^2.3.0" ngx-moment@^5.0.0: version "5.0.0" @@ -8884,11 +8909,6 @@ ngx-ui-switch@^11.0.1: resolved "https://registry.yarnpkg.com/ngx-ui-switch/-/ngx-ui-switch-11.0.1.tgz#c7f1e97ebe698f827a26f49951b50492b22c7839" integrity sha512-N8QYT/wW+xJdyh/aeebTSLPA6Sgrwp69H6KAcW0XZueg/LF+FKiqyG6Po/gFHq2gDhLikwyJEMpny8sudTI08w== -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - nice-napi@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" @@ -8918,9 +8938,9 @@ node-fetch@^2.6.1: whatwg-url "^5.0.0" node-forge@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.2.1.tgz#82794919071ef2eb5c509293325cec8afd0fd53c" - integrity sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w== + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== node-gyp-build@^4.2.2: version "4.3.0" @@ -9203,6 +9223,13 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -11135,10 +11162,10 @@ rxjs-report-usage@^1.0.4: glob "~7.2.0" prompts "~2.4.2" -rxjs-spy@^7.5.3: - version "7.5.3" - resolved "https://registry.yarnpkg.com/rxjs-spy/-/rxjs-spy-7.5.3.tgz#0194bc23ed0c30fb6a61f8bccbc8090e545b91b9" - integrity sha512-8QsSL6Ma51dTeaJ5Q9zWqhqnCSEkDf56Evs1gUsI9N22oB7bYrPMMx4UnoifNGc+Pko2sGX/xydzinLwGO+2pw== +rxjs-spy@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/rxjs-spy/-/rxjs-spy-8.0.2.tgz#dd510bdb58d798e0bc23121ab67714dd6fd95f88" + integrity sha512-w2yc+EiwYA8J97hxqMD+pxGZkNbRCQwxR660r4nw4Soa8kCvatsdSRc0THndYk9uk6SvZy2RNyiVcxfX39pWpw== dependencies: "@types/circular-json" "^0.4.0" "@types/stacktrace-js" "^0.0.33" @@ -11147,7 +11174,7 @@ rxjs-spy@^7.5.3: rxjs-report-usage "^1.0.4" stacktrace-gps "^3.0.2" -rxjs@6.6.7, rxjs@^6.5.4, rxjs@^6.5.5, rxjs@^6.6.3, rxjs@~6.6.0: +rxjs@6.6.7, rxjs@^6.5.4, rxjs@^6.5.5, rxjs@~6.6.0: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== @@ -11168,6 +11195,13 @@ rxjs@^7.2.0, rxjs@^7.5.1: dependencies: tslib "^2.1.0" +rxjs@^7.5.5: + version "7.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f" + integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -11268,7 +11302,7 @@ schema-utils@2.7.0: ajv "^6.12.2" ajv-keywords "^3.4.1" -schema-utils@^2.6.1, schema-utils@^2.6.5, schema-utils@^2.6.6: +schema-utils@^2.6.5, schema-utils@^2.6.6: version "2.7.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== @@ -11392,10 +11426,24 @@ send@0.17.2: range-parser "~1.2.1" statuses "~1.5.0" -serialize-javascript@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" - integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" serialize-javascript@^4.0.0: version "4.0.0" @@ -11451,6 +11499,16 @@ serve-static@1.14.2: parseurl "~1.3.3" send "0.17.2" +serve-static@^1.14.1: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + server-destroy@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd" @@ -13021,7 +13079,7 @@ webpack-merge@5.8.0, webpack-merge@^5.7.3: clone-deep "^4.0.1" wildcard "^2.0.0" -webpack-sources@^1.0.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==