diff --git a/.eslintrc.json b/.eslintrc.json index 6d5aa89db7..b95b54b979 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,7 +6,8 @@ "eslint-plugin-import", "eslint-plugin-jsdoc", "eslint-plugin-deprecation", - "eslint-plugin-unused-imports" + "unused-imports", + "eslint-plugin-lodash" ], "overrides": [ { @@ -202,7 +203,13 @@ "deprecation/deprecation": "warn", "import/order": "off", - "import/no-deprecated": "warn" + "import/no-deprecated": "warn", + "import/no-namespace": "error", + "unused-imports/no-unused-imports": "error", + "lodash/import-scope": [ + "error", + "method" + ] } }, { diff --git a/.gitattributes b/.gitattributes index dfe0770424..406640bfcc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,16 @@ -# Auto detect text files and perform LF normalization +# By default, auto detect text files and perform LF normalization +# This ensures code is always checked in with LF line endings * text=auto + +# JS and TS files must always use LF for Angular tools to work +# Some Angular tools expect LF line endings, even on Windows. +# This ensures Windows always checks out these files with LF line endings +# We've copied many of these rules from https://github.com/angular/angular-cli/ +*.js eol=lf +*.ts eol=lf +*.json eol=lf +*.json5 eol=lf +*.css eol=lf +*.scss eol=lf +*.html eol=lf +*.svg eol=lf \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04d426d091..c58e09edf2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ name: Build # Run this Build for all pushes / PRs to current branch on: [push, pull_request] +permissions: + contents: read # to fetch code (actions/checkout) + jobs: tests: runs-on: ubuntu-latest @@ -29,11 +32,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v3 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -58,7 +61,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Cache Yarn dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: # Cache entire Yarn cache directory (see previous step) path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -85,7 +88,7 @@ jobs: # 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 + uses: codecov/codecov-action@v3 if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration @@ -100,7 +103,7 @@ jobs: # https://github.com/cypress-io/github-action # (NOTE: to run these e2e tests locally, just use 'ng e2e') - name: Run e2e tests (integration tests) - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v4 with: # Run tests in Chrome, headless mode browser: chrome @@ -116,7 +119,7 @@ jobs: # Cypress always creates a video of all e2e tests (whether they succeeded or failed) # Save those in an Artifact - name: Upload e2e test videos to Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: always() with: name: e2e-test-videos @@ -125,7 +128,7 @@ jobs: # If e2e tests fail, Cypress creates a screenshot of what happened # Save those in an Artifact - name: Upload e2e test failure screenshots to Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: failure() with: name: e2e-test-screenshots diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 64303ca8bb..908c5c34fd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,6 +12,9 @@ on: - 'dspace-**' pull_request: +permissions: + contents: read # to fetch code (actions/checkout) + jobs: docker: # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' @@ -39,11 +42,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v3 # https://github.com/docker/setup-buildx-action - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 # https://github.com/docker/setup-qemu-action - name: Set up QEMU emulation to build for multiple architectures @@ -53,7 +56,7 @@ jobs: - name: Login to DockerHub # Only login if not a PR, as PRs only trigger a Docker build and not a push if: github.event_name != 'pull_request' - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} @@ -65,7 +68,7 @@ jobs: # Get Metadata for docker_build step below - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image id: meta_build - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v4 with: images: dspace/dspace-angular tags: ${{ env.IMAGE_TAGS }} @@ -74,7 +77,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push 'dspace-angular' image id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile diff --git a/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index 6b9a273ab6..5d7c1c30f7 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -5,25 +5,22 @@ on: issues: types: [opened] +permissions: {} jobs: automation: runs-on: ubuntu-latest steps: # Add the new issue to a project board, if it needs triage - # See https://github.com/marketplace/actions/create-project-card-action - - name: Add issue to project board + # See https://github.com/actions/add-to-project + - name: Add issue to triage board # Only add to project board if issue is flagged as "needs triage" or has no labels # NOTE: By default we flag new issues as "needs triage" in our issue template if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') - uses: technote-space/create-project-card-action@v1 + uses: actions/add-to-project@v0.3.0 # Note, the authentication token below is an ORG level Secret. - # It must be created/recreated manually via a personal access token with "public_repo" and "admin:org" permissions + # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token # This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific) with: - GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }} - PROJECT: DSpace Backlog - COLUMN: Triage - CHECK_ORG_PROJECT: true - # Ignore errors - continue-on-error: true + github-token: ${{ secrets.TRIAGE_PROJECT_TOKEN }} + project-url: https://github.com/orgs/DSpace/projects/24 diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml index dcbab18f1b..a840a4fd17 100644 --- a/.github/workflows/label_merge_conflicts.yml +++ b/.github/workflows/label_merge_conflicts.yml @@ -5,21 +5,32 @@ name: Check for merge conflicts # NOTE: This means merge conflicts are only checked for when a PR is merged to main. on: push: - branches: - - main + branches: [ main ] + # So that the `conflict_label_name` is removed if conflicts are resolved, + # we allow this to run for `pull_request_target` so that github secrets are available. + pull_request_target: + types: [ synchronize ] + +permissions: {} jobs: triage: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' + if: github.repository == 'dspace/dspace-angular' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - # See: https://github.com/mschilde/auto-label-merge-conflicts/ + # See: https://github.com/prince-chrismc/label-merge-conflicts-action - name: Auto-label PRs with merge conflicts - uses: mschilde/auto-label-merge-conflicts@v2.0 + uses: prince-chrismc/label-merge-conflicts-action@v2 # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved. # Note, the authentication token is created automatically # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token with: - CONFLICT_LABEL_NAME: 'merge conflict' - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Ignore errors - continue-on-error: true + conflict_label_name: 'merge conflict' + github_token: ${{ secrets.GITHUB_TOKEN }} + conflict_comment: | + Hi @${author}, + Conflicts have been detected against the base branch. + Please [resolve these conflicts](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/about-merge-conflicts) as soon as you can. Thanks! \ No newline at end of file diff --git a/README.md b/README.md index cef95a45fa..0ede4d4d19 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ If needing to update default configurations values for production, update local - Update `environment.production.ts` file in `src/environment/` for a `production` environment; -The environment object is provided for use as import in code and is extended with he runtime configuration on bootstrap of the application. +The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application. > Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap. @@ -351,7 +351,7 @@ Documentation Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/ -Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of htis codebase. +Some UI specific configuration documentation is also found in the [`./docs`](docs) folder of this codebase. ### Building code documentation diff --git a/config/config.example.yml b/config/config.example.yml index 3f88b32324..27400f0041 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -14,6 +14,8 @@ ui: rateLimiter: windowMs: 60000 # 1 minute max: 500 # limit each IP to 500 requests per windowMs + # Trust X-FORWARDED-* headers from proxies (default = true) + useProxies: true # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually @@ -150,12 +152,24 @@ languages: - code: fi label: Suomi active: true + - code: sv + label: Svenska + active: true - code: tr label: Türkçe active: true + - code: kk + label: Қазақ + active: true - code: bn label: বাংলা active: true + - code: hi + label: हिंदी + active: true + - code: el + label: Ελληνικά + active: true # Browse-By Pages browseBy: @@ -165,6 +179,27 @@ browseBy: fiveYearLimit: 30 # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) defaultLowerLimit: 1900 + # If true, thumbnail images for items will be added to BOTH search and browse result lists. + showThumbnails: true + # The number of entries in a paginated browse results list. + # Rounded to the nearest size in the list of selectable sizes on the + # settings menu. + pageSize: 20 + +communityList: + # No. of communities to list per expansion (show more) + pageSize: 20 + +homePage: + recentSubmissions: + # The number of item showing in recent submission components + pageSize: 5 + # Sort record of recent submission + sortField: 'dc.date.accessioned' + topLevelCommunityList: + # No. of communities to list per page on the home page + # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 + pageSize: 5 # Item Config item: @@ -240,7 +275,7 @@ themes: # The default bundles that should always be displayed as suggestions when you upload a new bundle bundle: - - standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ] + standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ] # Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video'). # For images, this enables a gallery viewer where you can zoom or page through images. @@ -248,3 +283,16 @@ bundle: mediaViewer: image: false video: false + +# Whether the end user agreement is required before users use the repository. +# If enabled, the user will be required to accept the agreement before they can use the repository. +# And whether the privacy statement should exist or not. +info: + enableEndUserAgreement: true + enablePrivacyStatement: true + +# Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/) +# display in supported metadata fields. By default, only dc.description.abstract is supported. +markdown: + enabled: false + mathjax: false \ No newline at end of file diff --git a/cypress/.gitignore b/cypress/.gitignore index 99bd2a6312..645beff45f 100644 --- a/cypress/.gitignore +++ b/cypress/.gitignore @@ -1,2 +1,3 @@ screenshots/ videos/ +downloads/ diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/integration/my-dspace.spec.ts index fa923dbcbc..48f44eecb9 100644 --- a/cypress/integration/my-dspace.spec.ts +++ b/cypress/integration/my-dspace.spec.ts @@ -4,10 +4,11 @@ import { testA11y } from 'cypress/support/utils'; describe('My DSpace page', () => { it('should display recent submissions and pass accessibility tests', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); - cy.visit('/mydspace'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.get('ds-my-dspace-page').should('exist'); // At least one recent submission should be displayed @@ -36,10 +37,11 @@ describe('My DSpace page', () => { }); it('should have a working detailed view that passes accessibility tests', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); - cy.visit('/mydspace'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.get('ds-my-dspace-page').should('exist'); // Click button in sidebar to display detailed view @@ -61,9 +63,11 @@ describe('My DSpace page', () => { // NOTE: Deleting existing submissions is exercised by submission.spec.ts it('should let you start a new submission & edit in-progress submissions', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); cy.visit('/mydspace'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // Open the New Submission dropdown cy.get('button[data-test="submission-dropdown"]').click(); // Click on the "Item" type in that dropdown @@ -131,9 +135,11 @@ describe('My DSpace page', () => { }); it('should let you import from external sources', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); cy.visit('/mydspace'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // Open the New Import dropdown cy.get('button[data-test="import-dropdown"]').click(); // Click on the "Item" type in that dropdown diff --git a/cypress/integration/submission.spec.ts b/cypress/integration/submission.spec.ts index 009c50115b..9eef596b02 100644 --- a/cypress/integration/submission.spec.ts +++ b/cypress/integration/submission.spec.ts @@ -6,11 +6,12 @@ describe('New Submission page', () => { // NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts it('should create a new submission when using /submit path & pass accessibility', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); - // Test that calling /submit with collection & entityType will create a new submission cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // Should redirect to /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); @@ -33,11 +34,12 @@ describe('New Submission page', () => { }); it('should block submission & show errors if required fields are missing', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); - // Create a new submission cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // Attempt an immediate deposit without filling out any fields cy.get('button#deposit').click(); @@ -92,11 +94,12 @@ describe('New Submission page', () => { }); it('should allow for deposit if all required fields completed & file uploaded', () => { - cy.login(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); - // Create a new submission cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + // Fill out all required fields (Title, Date) cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); cy.get('input#dc_date_issued_year').type('2022'); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 30951d46f1..04c217aa0f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -19,6 +19,14 @@ declare global { * @param password password to login as */ login(email: string, password: string): typeof login; + + /** + * Login via form before accessing the next page. Useful to fill out login + * form when a cy.visit() call is to an a page which requires authentication. + * @param email email to login as + * @param password password to login as + */ + loginViaForm(email: string, password: string): typeof loginViaForm; } } } @@ -26,6 +34,8 @@ declare global { /** * Login user via REST API directly, and pass authentication token to UI via * the UI's dsAuthInfo cookie. + * WARNING: WHILE THIS METHOD WORKS, OCCASIONALLY RANDOM AUTHENTICATION ERRORS OCCUR. + * At this time "loginViaForm()" seems more consistent/stable. * @param email email to login as * @param password password to login as */ @@ -81,3 +91,20 @@ function login(email: string, password: string): void { } // Add as a Cypress command (i.e. assign to 'cy.login') Cypress.Commands.add('login', login); + + +/** + * Login user via displayed login form + * @param email email to login as + * @param password password to login as + */ + function loginViaForm(email: string, password: string): void { + // Enter email + cy.get('ds-log-in [data-test="email"]').type(email); + // Enter password + cy.get('ds-log-in [data-test="password"]').type(password); + // Click login button + cy.get('ds-log-in [data-test="login-button"]').click(); +} +// Add as a Cypress command (i.e. assign to 'cy.loginViaForm') +Cypress.Commands.add('loginViaForm', loginViaForm); \ No newline at end of file diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 024b46cdde..70da23f044 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -24,7 +24,7 @@ import 'cypress-axe'; 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}'); + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); }); // For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. diff --git a/package.json b/package.json index dbb4cca8a5..705dc8e345 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "0.0.0", + "version": "7.5.0-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -10,7 +10,7 @@ "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", "preserve": "yarn base-href", - "serve": "ng serve --configuration development", + "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build --configuration development", @@ -78,6 +78,7 @@ "@nguniversal/express-engine": "^13.0.2", "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", + "@types/grecaptcha": "^3.0.4", "angular-idle-preload": "3.0.0", "angulartics2": "^12.0.0", "axios": "^0.27.2", @@ -88,6 +89,8 @@ "compression": "^1.7.4", "cookie-parser": "1.4.5", "core-js": "^3.7.0", + "date-fns": "^2.29.3", + "date-fns-tz": "^1.3.7", "deepmerge": "^4.2.2", "express": "^4.17.1", "express-rate-limit": "^5.1.3", @@ -102,20 +105,21 @@ "json5": "^2.1.3", "jsonschema": "1.4.0", "jwt-decode": "^3.1.2", - "klaro": "^0.7.10", + "klaro": "^0.7.18", "lodash": "^4.17.21", + "markdown-it": "^13.0.1", + "markdown-it-mathjax3": "^4.3.1", "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", - "moment": "^2.29.2", "morgan": "^1.10.0", "ng-mocks": "^13.1.1", "ng2-file-upload": "1.4.0", "ng2-nouislider": "^1.8.3", "ngx-infinite-scroll": "^10.0.1", - "ngx-moment": "^5.0.0", "ngx-pagination": "5.0.0", "ngx-sortablejs": "^11.1.0", + "ngx-ui-switch": "^11.0.1", "nouislider": "^14.6.3", "pem": "1.14.4", "postcss-cli": "^9.1.0", @@ -123,13 +127,13 @@ "react-copy-to-clipboard": "^5.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.5", + "sanitize-html": "^2.7.2", "sortablejs": "1.13.0", "tslib": "^2.0.0", "url-parse": "^1.5.6", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "~0.11.5", - "ngx-ui-switch": "^11.0.1" + "zone.js": "~0.11.5" }, "devDependencies": { "@angular-builders/custom-webpack": "~13.1.0", @@ -155,16 +159,17 @@ "@types/js-cookie": "2.2.6", "@types/lodash": "^4.14.165", "@types/node": "^14.14.9", + "@types/sanitize-html": "^2.6.2", "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", - "axe-core": "^4.3.3", + "axe-core": "^4.4.3", "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": "9.7.0", "cypress-axe": "^0.14.0", "debug-loader": "^0.0.1", "deep-freeze": "0.0.1", @@ -173,6 +178,7 @@ "eslint-plugin-deprecation": "^1.3.2", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsdoc": "^38.0.6", + "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-unused-imports": "^2.0.0", "express-static-gzip": "^2.1.5", "fork-ts-checker-webpack-plugin": "^6.0.3", diff --git a/scripts/base-href.ts b/scripts/base-href.ts index aee547b46d..7212e1c516 100644 --- a/scripts/base-href.ts +++ b/scripts/base-href.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import { existsSync, writeFileSync } from 'fs'; import { join } from 'path'; import { AppConfig } from '../src/config/app-config.interface'; @@ -16,7 +16,7 @@ const appConfig: AppConfig = buildAppConfig(); const angularJsonPath = join(process.cwd(), 'angular.json'); -if (!fs.existsSync(angularJsonPath)) { +if (!existsSync(angularJsonPath)) { console.error(`Error:\n${angularJsonPath} does not exist\n`); process.exit(1); } @@ -30,7 +30,7 @@ try { angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref; - fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n'); + writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n'); } catch (e) { console.error(e); } diff --git a/scripts/env-to-yaml.ts b/scripts/env-to-yaml.ts index c2dd1cf0ca..6e8153f4c1 100644 --- a/scripts/env-to-yaml.ts +++ b/scripts/env-to-yaml.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as yaml from 'js-yaml'; +import { existsSync, writeFileSync } from 'fs'; +import { dump } from 'js-yaml'; import { join } from 'path'; /** @@ -18,7 +18,7 @@ if (args[0] === undefined) { const envFullPath = join(process.cwd(), args[0]); -if (!fs.existsSync(envFullPath)) { +if (!existsSync(envFullPath)) { console.error(`Error:\n${envFullPath} does not exist\n`); process.exit(1); } @@ -26,10 +26,10 @@ if (!fs.existsSync(envFullPath)) { try { const env = require(envFullPath).environment; - const config = yaml.dump(env); + const config = dump(env); if (args[1]) { const ymlFullPath = join(process.cwd(), args[1]); - fs.writeFileSync(ymlFullPath, config); + writeFileSync(ymlFullPath, config); } else { console.log(config); } diff --git a/scripts/serve.ts b/scripts/serve.ts index bf5506b8bd..ee8570a45c 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -1,4 +1,4 @@ -import * as child from 'child_process'; +import { spawn } from 'child_process'; import { AppConfig } from '../src/config/app-config.interface'; import { buildAppConfig } from '../src/config/config.server'; @@ -7,8 +7,9 @@ const appConfig: AppConfig = buildAppConfig(); /** * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl + * Any CLI arguments given to this script are patched through to `ng serve` as well. */ -child.spawn( - `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, +spawn( + `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl} ${process.argv.slice(2).join(' ')} --configuration development`, { stdio: 'inherit', shell: true } ); diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts old mode 100755 new mode 100644 diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts index b2a3ebd1af..9066777c42 100644 --- a/scripts/test-rest.ts +++ b/scripts/test-rest.ts @@ -1,5 +1,5 @@ -import * as http from 'http'; -import * as https from 'https'; +import { request } from 'http'; +import { request as https_request } from 'https'; import { AppConfig } from '../src/config/app-config.interface'; import { buildAppConfig } from '../src/config/config.server'; @@ -20,9 +20,15 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`); // If SSL enabled, test via HTTPS, else via HTTP if (appConfig.rest.ssl) { - const req = https.request(restUrl, (res) => { + const req = https_request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); @@ -33,9 +39,15 @@ if (appConfig.rest.ssl) { req.end(); } else { - const req = http.request(restUrl, (res) => { + const req = request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); diff --git a/server.ts b/server.ts index 9fe03fe5b5..608c214076 100644 --- a/server.ts +++ b/server.ts @@ -19,14 +19,17 @@ import 'zone.js/node'; import 'reflect-metadata'; import 'rxjs'; -import axios from 'axios'; -import * as pem from 'pem'; -import * as https from 'https'; +/* eslint-disable import/no-namespace */ 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'; +/* eslint-enable import/no-namespace */ + +import axios from 'axios'; +import { createCertificate } from 'pem'; +import { createServer } from 'https'; +import { json } from 'body-parser'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -48,6 +51,7 @@ import { ServerAppModule } from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; +import { logStartupMessage } from './startup-message'; /* * Set path for the browser application's dist folder @@ -75,6 +79,10 @@ export function app() { */ const server = express(); + // Tell Express to trust X-FORWARDED-* headers from proxies + // See https://expressjs.com/en/guide/behind-proxies.html + server.set('trust proxy', environment.ui.useProxies); + /* * If production mode is enabled in the environment file: * - Enable Angular's production mode @@ -105,7 +113,7 @@ export function app() { * Add parser for request bodies * See [morgan](https://github.com/expressjs/body-parser) */ - server.use(bodyParser.json()); + server.use(json()); // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) server.engine('html', (_, options, callback) => @@ -261,7 +269,7 @@ function serverStarted() { * @param keys SSL credentials */ function createHttpsServer(keys) { - https.createServer({ + createServer({ key: keys.serviceKey, cert: keys.certificate }, app).listen(environment.ui.port, environment.ui.host, () => { @@ -281,6 +289,8 @@ function run() { } function start() { + logStartupMessage(environment); + /* * If SSL is enabled * - Read credentials from configuration files @@ -313,7 +323,7 @@ function start() { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] - pem.createCertificate({ + createCertificate({ days: 1, selfSigned: true }, (error, keys) => { diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index 7ef02a76cf..2d87f21d26 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -45,7 +45,7 @@ - + ) => { if (restResponse.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name})); - this.reset(); } else { this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); } diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index 41ae67423c..e9cc48aee3 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -36,12 +36,12 @@ - +
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
- + { }); groupsDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '' }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 05fc3189d0..7d607647e3 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -265,7 +265,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { if (eperson != null) { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, { + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { currentPage: 1, elementsPerPage: this.config.pageSize }); @@ -297,7 +297,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }), switchMap(([eperson, findListOptions]) => { if (eperson != null) { - return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); + return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } return observableOf(undefined); }) @@ -554,7 +554,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ private updateGroups(options) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); })); } } diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts index 0b19b17100..8d0ddf0a85 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -53,7 +53,7 @@ describe('MembersListComponent', () => { activeGroup: activeGroup, epersonMembers: epersonMembers, subgroupMembers: subgroupMembers, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, searchByScope(scope: string, query: string): Observable>> { diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index 54d144da51..169d009d63 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -10,7 +10,7 @@ import { combineLatest as observableCombineLatest, ObservedValueOf, } from 'rxjs'; -import { map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; @@ -129,7 +129,7 @@ export class MembersListComponent implements OnInit, OnDestroy { this.subs.set(SubKey.MembersDTO, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((currentPagination) => { - return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { + return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize } @@ -144,7 +144,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -153,8 +153,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) @@ -171,10 +171,10 @@ export class MembersListComponent implements OnInit, OnDestroy { return this.groupDataService.getActiveGroup().pipe(take(1), mergeMap((group: Group) => { if (group != null) { - return this.ePersonDataService.findAllByHref(group._links.epersons.href, { + return this.ePersonDataService.findListByHref(group._links.epersons.href, { currentPage: 1, elementsPerPage: 9999 - }, false) + }) .pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), @@ -209,7 +209,6 @@ export class MembersListComponent implements OnInit, OnDestroy { if (activeGroup != null) { const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup); - this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -274,7 +273,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -283,8 +282,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) @@ -315,7 +314,6 @@ export class MembersListComponent implements OnInit, OnDestroy { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); - this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href); } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); } diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts index bee5126e09..1ca6c88c5f 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts @@ -65,7 +65,7 @@ describe('SubgroupsListComponent', () => { getSubgroups(): Group { return this.activeGroup; }, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return this.subgroups$.pipe( map((currentGroups: Group[]) => { return createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), currentGroups)); diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts index 9930dd61ea..5f1700e07d 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts @@ -115,7 +115,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { this.subs.set( SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( - switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, { + switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { currentPage: config.currentPage, elementsPerPage: config.pageSize }, @@ -139,7 +139,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup.uuid === possibleSubgroup.uuid) { return observableOf(false); } else { - return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, { + return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, { currentPage: 1, elementsPerPage: 9999 }) 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 237dedde09..ebbd223599 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -33,7 +33,7 @@
- + { mockGroups = [GroupMock, GroupMock2]; mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ @@ -97,7 +97,7 @@ describe('GroupRegistryComponent', () => { }; groupsDataServiceStub = { allGroups: mockGroups, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ 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 1770762a34..70c9b22852 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -5,11 +5,12 @@ import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest as observableCombineLatest, + EMPTY, Observable, of as observableOf, Subscription } from 'rxjs'; -import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, defaultIfEmpty, 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'; @@ -144,7 +145,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe( switchMap((isSiteAdmin: boolean) => { - return observableCombineLatest(groups.page.map((group: Group) => { + return observableCombineLatest([...groups.page.map((group: Group) => { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) { return observableCombineLatest([ this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self), @@ -165,8 +166,10 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } ) ); + } else { + return EMPTY; } - })).pipe(map((dtos: GroupDtoModel[]) => { + })]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => { return buildPaginatedList(groups.pageInfo, dtos); })); }) @@ -213,7 +216,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { * @param group */ getMembers(group: Group): Observable>> { - return this.ePersonDataService.findAllByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); + return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); } /** @@ -221,7 +224,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { * @param group */ getSubgroups(group: Group): Observable>> { - return this.groupService.findAllByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); + return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); } /** diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html new file mode 100644 index 0000000000..dbc8c74437 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -0,0 +1,35 @@ +
+ +

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

+

+ selected collection: {{getDspaceObjectName()}}  + {{'admin.batch-import.page.remove' | translate}} +

+

+ +

+
+
+ + +
+ + {{'admin.batch-import.page.validateOnly.hint' | translate}} + +
+ + + + +
+ + +
+
diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts new file mode 100644 index 0000000000..36ba1137c9 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts @@ -0,0 +1,151 @@ +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { BatchImportPageComponent } from './batch-import-page.component'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; +import { FileValidator } from '../../shared/utils/require-file.validator'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + BATCH_IMPORT_SCRIPT_NAME, + ScriptDataService +} from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; + +describe('BatchImportPageComponent', () => { + let component: BatchImportPageComponent; + let fixture: ComponentFixture; + + let notificationService: NotificationsServiceStub; + let scriptService: any; + let router; + let locationStub; + + function init() { + notificationService = new NotificationsServiceStub(); + scriptService = jasmine.createSpyObj('scriptService', + { + invoke: createSuccessfulRemoteDataObject$({ processId: '46' }) + } + ); + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); + locationStub = jasmine.createSpyObj('location', { + back: jasmine.createSpy('back') + }); + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]) + ], + declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator], + providers: [ + { provide: NotificationsService, useValue: notificationService }, + { provide: ScriptDataService, useValue: scriptService }, + { provide: Router, useValue: router }, + { provide: Location, useValue: locationStub }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BatchImportPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('if back button is pressed', () => { + beforeEach(fakeAsync(() => { + const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('should do location.back', () => { + expect(locationStub.back).toHaveBeenCalled(); + }); + }); + + describe('if file is set', () => { + let fileMock: File; + + beforeEach(() => { + fileMock = new File([''], 'filename.zip', { type: 'application/zip' }); + component.setFile(fileMock); + }); + + describe('if proceed button is pressed without validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = false; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + ]; + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' })); + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + Object.assign(new ProcessParameter(), { name: '--add' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed is pressed; but script invoke fails', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500)); + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('error notification is shown', () => { + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts new file mode 100644 index 0000000000..7171c67585 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts @@ -0,0 +1,124 @@ +import { Component } from '@angular/core'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Process } from '../../process-page/processes/process.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { + ImportBatchSelectorComponent +} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { take } from 'rxjs/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-batch-import-page', + templateUrl: './batch-import-page.component.html' +}) +export class BatchImportPageComponent { + /** + * The current value of the file + */ + fileObject: File; + + /** + * The validate only flag + */ + validateOnly = true; + /** + * dso object for community or collection + */ + dso: DSpaceObject = null; + + public constructor(private location: Location, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + private scriptDataService: ScriptDataService, + private router: Router, + private modalService: NgbModal, + private dsoNameService: DSONameService) { + } + + /** + * Set file + * @param file + */ + setFile(file) { + this.fileObject = file; + } + + /** + * When return button is pressed go to previous location + */ + public onReturn() { + this.location.back(); + } + + public selectCollection() { + const modalRef = this.modalService.open(ImportBatchSelectorComponent); + modalRef.componentInstance.response.pipe(take(1)).subscribe((dso) => { + this.dso = dso || null; + }); + } + + /** + * Starts import-metadata script with --zip fileName (and the selected file) + */ + public importMetadata() { + if (this.fileObject == null) { + this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); + } else { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }), + Object.assign(new ProcessParameter(), { name: '--add' }) + ]; + if (this.dso) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); + } + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } + + this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translate.get('process.new.notification.success.title'); + const content = this.translate.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + } + }); + } + } + + /** + * return selected dspace object name + */ + getDspaceObjectName(): string { + if (this.dso) { + return this.dsoNameService.getName(this.dso); + } + return null; + } + + /** + * remove selected dso object + */ + removeDspaceObject(): void { + this.dso = null; + } +} 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 deb16c0d73..4236d152dc 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 @@ -1,12 +1,8 @@ import { Location } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; -import { AuthService } from '../../core/auth/auth.service'; import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 6569b2d4c8..c4304806ce 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -13,32 +13,34 @@ [paginationOptions]="pageConfig" [pageInfoState]="(bitstreamFormats | async)?.payload" [collectionSize]="(bitstreamFormats | async)?.payload?.totalElements" - [hideGear]="true" + [hideGear]="false" [hidePagerWhenSinglePage]="true">
- - - - - - + + + + + + + - - - - - - + + + + + + +
{{'admin.registries.bitstream-formats.table.name' | translate}}{{'admin.registries.bitstream-formats.table.mimetype' | translate}}{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}
{{'admin.registries.bitstream-formats.table.id' | translate}}{{'admin.registries.bitstream-formats.table.name' | translate}}{{'admin.registries.bitstream-formats.table.mimetype' | translate}}{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}
- - {{bitstreamFormat.shortDescription}}{{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.table.internal' | translate}}){{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
+ + {{bitstreamFormat.id}}{{bitstreamFormat.shortDescription}}{{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.table.internal' | translate}}){{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 7d3a726eec..8a44240b7e 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -20,14 +20,12 @@ import { TestScheduler } from 'rxjs/testing'; import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ + createSuccessfulRemoteDataObject$, + createFailedRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('BitstreamFormatsComponent', () => { let comp: BitstreamFormatsComponent; @@ -85,10 +83,6 @@ describe('BitstreamFormatsComponent', () => { ]; const mockFormatsRD = createSuccessfulRemoteDataObject(createPaginatedList(mockFormatsList)); - const pagination = Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }); - const sort = new SortOptions('score', SortDirection.DESC); - const findlistOptions = Object.assign(new FindListOptions(), { currentPage: 1, elementsPerPage: 20 }); - const initAsync = () => { notificationsServiceStub = new NotificationsServiceStub(); @@ -135,16 +129,19 @@ describe('BitstreamFormatsComponent', () => { }); it('should contain the correct formats', () => { - const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement; + const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(3)')).nativeElement; expect(unknownName.textContent).toBe('Unknown'); - const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(2)')).nativeElement; + const UUID: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement; + expect(UUID.textContent).toBe('test-uuid-1'); + + const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(3)')).nativeElement; expect(licenseName.textContent).toBe('License'); - const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(2)')).nativeElement; + const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(3)')).nativeElement; expect(ccLicenseName.textContent).toBe('CC License'); - const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(2)')).nativeElement; + const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(3)')).nativeElement; expect(adobeName.textContent).toBe('Adobe PDF'); }); }); @@ -246,7 +243,7 @@ describe('BitstreamFormatsComponent', () => { )); beforeEach(initBeforeEach); - it('should clear bitstream formats ', () => { + it('should clear bitstream formats and show a success notification', () => { comp.deleteFormats(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); @@ -275,7 +272,7 @@ describe('BitstreamFormatsComponent', () => { selectBitstreamFormat: {}, deselectBitstreamFormat: {}, deselectAllBitstreamFormats: {}, - delete: observableOf(false), + delete: createFailedRemoteDataObject$(), clearBitStreamFormatRequests: observableOf('cleared') }); @@ -295,7 +292,7 @@ describe('BitstreamFormatsComponent', () => { )); beforeEach(initBeforeEach); - it('should clear bitstream formats ', () => { + it('should clear bitstream formats and show an error notification', () => { comp.deleteFormats(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index 89d8ac29f3..162bf2bdb2 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -1,18 +1,18 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, zip } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable} from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; -import { map, switchMap, take } from 'rxjs/operators'; -import { hasValue } from '../../../shared/empty.util'; +import { map, mergeMap, switchMap, take, toArray } from 'rxjs/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; /** * This component renders a list of bitstream formats @@ -28,21 +28,14 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { */ bitstreamFormats: Observable>>; - /** - * The current pagination configuration for the page used by the FindAll method - * Currently simply renders all bitstream formats - */ - config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 20 - }); - /** * The current pagination configuration for the page * Currently simply renders all bitstream formats */ pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'rbp', - pageSize: 20 + pageSize: 20, + pageSizeOptions: [20, 40, 60, 80, 100] }); constructor(private notificationsService: NotificationsService, @@ -50,7 +43,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { private translateService: TranslateService, private bitstreamFormatService: BitstreamFormatDataService, private paginationService: PaginationService, - ) { + ) { } @@ -58,31 +51,39 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { * Deletes the currently selected formats from the registry and updates the presented list */ deleteFormats() { - this.bitstreamFormatService.clearBitStreamFormatRequests().subscribe(); - this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(take(1)).subscribe( - (formats) => { - const tasks$ = []; - for (const format of formats) { - if (hasValue(format.id)) { - tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RemoteData) => response.hasSucceeded))); - } - } - zip(...tasks$).subscribe((results: boolean[]) => { - const successResponses = results.filter((result: boolean) => result); - const failedResponses = results.filter((result: boolean) => !result); - if (successResponses.length > 0) { - this.showNotification(true, successResponses.length); - } - if (failedResponses.length > 0) { - this.showNotification(false, failedResponses.length); - } + this.bitstreamFormatService.clearBitStreamFormatRequests(); + this.bitstreamFormatService.getSelectedBitstreamFormats().pipe( + take(1), + // emit all formats in the array one at a time + mergeMap((formats: BitstreamFormat[]) => formats), + // delete each format + mergeMap((format: BitstreamFormat) => this.bitstreamFormatService.delete(format.id).pipe( + // wait for each response to come back + getFirstCompletedRemoteData(), + // return a boolean to indicate whether a response succeeded + map((response: RemoteData) => response.hasSucceeded), + )), + // wait for all responses to come in and return them as a single array + toArray() + ).subscribe((results: boolean[]) => { + // Count the number of succeeded and failed deletions + const successResponses = results.filter((result: boolean) => result); + const failedResponses = results.filter((result: boolean) => !result); - this.deselectAll(); - - this.paginationService.resetPage(this.pageConfig.id); - }); + // Show a notification indicating the number of succeeded and failed deletions + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); } - ); + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + + // reset the selection + this.deselectAll(); + + // reload the page + this.paginationService.resetPage(this.pageConfig.id); + }); } /** @@ -140,7 +141,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { ngOnInit(): void { - this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( + this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe( switchMap((findListOptions: FindListOptions) => { return this.bitstreamFormatService.findAll(findListOptions); }) diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index 74bfc5f0a4..944288a7a5 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -19,10 +19,7 @@ import { RestResponse } from '../../../core/cache/response.models'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { PaginationService } from '../../../core/pagination/pagination.service'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('MetadataRegistryComponent', () => { let comp: MetadataRegistryComponent; diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html index 49fed0c847..1a867928f5 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -25,6 +25,7 @@ + {{'admin.registries.schema.fields.table.id' | translate}} {{'admin.registries.schema.fields.table.field' | translate}} {{'admin.registries.schema.fields.table.scopenote' | translate}} @@ -39,6 +40,7 @@ (change)="selectMetadataField(field, $event)"> + {{field.id}} {{schema?.prefix}}.{{field.element}}{{field.qualifier}} {{field.scopeNote}} diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index c4116dc9e0..2b660a6363 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -23,11 +23,8 @@ import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { VarDirective } from '../../../shared/utils/var.directive'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; -import { FindListOptions } from '../../../core/data/find-list-options.model'; describe('MetadataSchemaComponent', () => { let comp: MetadataSchemaComponent; @@ -169,10 +166,10 @@ describe('MetadataSchemaComponent', () => { }); it('should contain the correct fields', () => { - const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement; + const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(3)')).nativeElement; expect(editorField.textContent).toBe('mock.contributor.editor'); - const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement; + const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(3)')).nativeElement; expect(illustratorField.textContent).toBe('mock.contributor.illustrator'); }); diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index 782a7faa38..da622cae10 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths'; +import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ @@ -45,6 +46,12 @@ import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routi component: MetadataImportPageComponent, data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } }, + { + path: 'batch-import', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: BatchImportPageComponent, + data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } + }, ]) ], providers: [ diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 2cb0413bbc..1ea27b36b6 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -14,6 +14,14 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; describe('CollectionAdminSearchResultGridElementComponent', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -45,7 +53,11 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 17ce2cd7a1..996366e20a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -16,6 +16,14 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; describe('CommunityAdminSearchResultGridElementComponent', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -47,7 +55,11 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ], schemas: [NO_ERRORS_SCHEMA] }) 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 a6ea7e4946..e478aa3ef3 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 @@ -20,6 +20,12 @@ import { getMockThemeService } from '../../../../../shared/mocks/theme-service.m 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'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub'; +import { FileService } from '../../../../../core/shared/file.service'; +import { FileServiceStub } from '../../../../../shared/testing/file-service.stub'; +import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service'; +import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -64,6 +70,9 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts index b394caef56..8937847ff5 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('CollectionAdminSearchResultListElementComponent', () => { let component: CollectionAdminSearchResultListElementComponent; @@ -36,7 +38,8 @@ describe('CollectionAdminSearchResultListElementComponent', () => { ], declarations: [CollectionAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts index 155d7f7509..110d77b1e5 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts @@ -13,6 +13,8 @@ import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('CommunityAdminSearchResultListElementComponent', () => { let component: CommunityAdminSearchResultListElementComponent; @@ -36,7 +38,8 @@ describe('CommunityAdminSearchResultListElementComponent', () => { ], declarations: [CommunityAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts index 3774a07757..667e8edea9 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component.spec.ts @@ -10,6 +10,8 @@ import { ItemAdminSearchResultListElementComponent } from './item-admin-search-r import { Item } from '../../../../../core/shared/item.model'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; describe('ItemAdminSearchResultListElementComponent', () => { let component: ItemAdminSearchResultListElementComponent; @@ -33,7 +35,8 @@ describe('ItemAdminSearchResultListElementComponent', () => { ], declarations: [ItemAdminSearchResultListElementComponent], providers: [{ provide: TruncatableService, useValue: {} }, - { provide: DSONameService, useClass: DSONameServiceMock }], + { provide: DSONameService, useClass: DSONameServiceMock }, + { provide: APP_CONFIG, useValue: environment }], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index 14d5d38199..1e28b62626 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MenuService } from '../../../shared/menu/menu.service'; import { MenuServiceStub } from '../../../shared/testing/menu-service.stub'; -import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; +import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub'; import { Component } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.html b/src/app/admin/admin-sidebar/admin-sidebar.component.html index 84402c64e9..ef220b834b 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.html @@ -1,4 +1,4 @@ -