Merge branch 'main-upstream' into w2p-98211_advanced-workflow-actions

This commit is contained in:
Alexandre Vryghem
2023-01-11 14:22:41 +01:00
1790 changed files with 104235 additions and 48985 deletions

View File

@@ -2,10 +2,16 @@
# For additional information regarding the format and rule options, please see: # For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries # https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running: # You can see what browsers were selected by your queries by running:
# npx browserslist # npx browserslist
> 0.5% last 1 Chrome version
last 2 versions last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR Firefox ESR
not IE 9-11 # For IE 9-11 support, remove 'not'. not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

229
.eslintrc.json Normal file
View File

@@ -0,0 +1,229 @@
{
"root": true,
"plugins": [
"@typescript-eslint",
"@angular-eslint/eslint-plugin",
"eslint-plugin-import",
"eslint-plugin-jsdoc",
"eslint-plugin-deprecation",
"unused-imports",
"eslint-plugin-lodash"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./cypress/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"max-classes-per-file": [
"error",
1
],
"comma-dangle": [
"off",
"always-multiline"
],
"eol-last": [
"error",
"always"
],
"no-console": [
"error",
{
"allow": [
"log",
"warn",
"dir",
"timeLog",
"assert",
"clear",
"count",
"countReset",
"group",
"groupEnd",
"table",
"debug",
"info",
"dirxml",
"error",
"groupCollapsed",
"Console",
"profile",
"profileEnd",
"timeStamp",
"context"
]
}
],
"curly": "error",
"brace-style": [
"error",
"1tbs",
{
"allowSingleLine": true
}
],
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"radix": "error",
"guard-for-in": "error",
"no-bitwise": "error",
"no-restricted-imports": "error",
"no-caller": "error",
"no-debugger": "error",
"no-redeclare": "error",
"no-eval": "error",
"no-fallthrough": "error",
"no-trailing-spaces": "error",
"space-infix-ops": "error",
"keyword-spacing": "error",
"no-var": "error",
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint)
"prefer-spread": "off",
"no-underscore-dangle": "off",
// todo: disabled rules from eslint:recommended, consider re-enabling & fixing
"no-prototype-builtins": "off",
"no-useless-escape": "off",
"no-case-declarations": "off",
"no-extra-boolean-cast": "off",
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "ds",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "ds",
"style": "kebab-case"
}
],
"@angular-eslint/pipe-prefix": [
"error",
{
"prefixes": [
"ds"
]
}
],
"@angular-eslint/no-attribute-decorator": "error",
"@angular-eslint/no-forward-ref": "error",
"@angular-eslint/no-output-native": "warn",
"@angular-eslint/no-output-on-prefix": "warn",
"@angular-eslint/no-conflicting-lifecycle": "warn",
"@typescript-eslint/no-inferrable-types":[
"error",
{
"ignoreParameters": true
}
],
"@typescript-eslint/quotes": [
"error",
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"@typescript-eslint/semi": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "property",
"format": null
}
],
"@typescript-eslint/member-ordering": [
"error",
{
"default": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/restrict-plus-operands": "warn",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/require-await": "off",
"deprecation/deprecation": "warn",
"import/order": "off",
"import/no-deprecated": "warn",
"import/no-namespace": "error",
"unused-imports/no-unused-imports": "error",
"lodash/import-scope": [
"error",
"method"
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {
// todo: re-enable & fix errors
"@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off"
}
}
]
}

16
.gitattributes vendored Normal file
View File

@@ -0,0 +1,16 @@
# 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

View File

@@ -1,7 +1,7 @@
## References ## References
_Add references/links to any related issues or PRs. These may include:_ _Add references/links to any related issues or PRs. These may include:_
* Fixes #[issue-number] * Fixes #`issue-number` (if this fixes an issue ticket)
* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this) * Requires DSpace/DSpace#`pr-number` (if a REST API PR is required to test this)
## Description ## Description
Short summary of changes (1-2 sentences). Short summary of changes (1-2 sentences).
@@ -19,8 +19,10 @@ List of changes in this PR:
_This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_ _This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_
- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. - [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` - [ ] My PR passes [ESLint](https://eslint.org/) validation using `yarn lint`
- [ ] My PR doesn't introduce circular dependencies - [ ] My PR doesn't introduce circular dependencies (verified via `yarn check-circ-deps`)
- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. - [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). - [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. - [ ] If my PR includes new libraries/dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
- [ ] If my PR includes new features or configurations, I've provided basic technical documentation in the PR itself.
- [ ] If my PR fixes an issue ticket, I've [linked them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).

View File

@@ -6,34 +6,39 @@ name: Build
# Run this Build for all pushes / PRs to current branch # Run this Build for all pushes / PRs to current branch
on: [push, pull_request] on: [push, pull_request]
permissions:
contents: read # to fetch code (actions/checkout)
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
# The ci step will test the dspace-angular code against DSpace REST. # The ci step will test the dspace-angular code against DSpace REST.
# Direct that step to utilize a DSpace REST service that has been started in docker. # Direct that step to utilize a DSpace REST service that has been started in docker.
DSPACE_REST_HOST: localhost DSPACE_REST_HOST: 127.0.0.1
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false DSPACE_REST_SSL: false
# Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+
DSPACE_UI_HOST: 127.0.0.1
# When Chrome version is specified, we pin to a specific version of Chrome # When Chrome version is specified, we pin to a specific version of Chrome
# Comment this out to use the latest release # Comment this out to use the latest release
#CHROME_VERSION: "90.0.4430.212-1" #CHROME_VERSION: "90.0.4430.212-1"
strategy: strategy:
# Create a matrix of Node versions to test against (in parallel) # Create a matrix of Node versions to test against (in parallel)
matrix: matrix:
node-version: [12.x, 14.x] node-version: [16.x, 18.x]
# Do NOT exit immediately if one matrix job fails # Do NOT exit immediately if one matrix job fails
fail-fast: false fail-fast: false
# These are the actual CI steps to perform per job # These are the actual CI steps to perform per job
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout codebase - name: Checkout codebase
uses: actions/checkout@v2 uses: actions/checkout@v3
# https://github.com/actions/setup-node # https://github.com/actions/setup-node
- name: Install Node.js ${{ matrix.node-version }} - name: Install Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@@ -58,7 +63,7 @@ jobs:
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache Yarn dependencies - name: Cache Yarn dependencies
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
# Cache entire Yarn cache directory (see previous step) # Cache entire Yarn cache directory (see previous step)
path: ${{ steps.yarn-cache-dir-path.outputs.dir }} path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -70,7 +75,10 @@ jobs:
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Run lint - name: Run lint
run: yarn run lint run: yarn run lint --quiet
- name: Check for circular dependencies
run: yarn run check-circ-deps
- name: Run build - name: Run build
run: yarn run build:prod run: yarn run build:prod
@@ -79,11 +87,11 @@ jobs:
run: yarn run test:headless run: yarn run test:headless
# NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 # 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 # https://github.com/codecov/codecov-action
- name: Upload coverage to Codecov.io - name: Upload coverage to Codecov.io
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v3
if: matrix.node-version == '12.x' if: matrix.node-version == '16.x'
# Using docker-compose start backend using CI configuration # Using docker-compose start backend using CI configuration
# and load assetstore from a cached copy # and load assetstore from a cached copy
@@ -97,7 +105,7 @@ jobs:
# https://github.com/cypress-io/github-action # https://github.com/cypress-io/github-action
# (NOTE: to run these e2e tests locally, just use 'ng e2e') # (NOTE: to run these e2e tests locally, just use 'ng e2e')
- name: Run e2e tests (integration tests) - name: Run e2e tests (integration tests)
uses: cypress-io/github-action@v2 uses: cypress-io/github-action@v4
with: with:
# Run tests in Chrome, headless mode # Run tests in Chrome, headless mode
browser: chrome browser: chrome
@@ -106,14 +114,14 @@ jobs:
start: yarn run serve:ssr start: yarn run serve:ssr
# Wait for backend & frontend to be available # Wait for backend & frontend to be available
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready # NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
wait-on: http://localhost:8080/server/api/core/sites, http://localhost:4000 wait-on: http://127.0.0.1:8080/server/api/core/sites, http://127.0.0.1:4000
# Wait for 2 mins max for everything to respond # Wait for 2 mins max for everything to respond
wait-on-timeout: 120 wait-on-timeout: 120
# Cypress always creates a video of all e2e tests (whether they succeeded or failed) # Cypress always creates a video of all e2e tests (whether they succeeded or failed)
# Save those in an Artifact # Save those in an Artifact
- name: Upload e2e test videos to Artifacts - name: Upload e2e test videos to Artifacts
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: e2e-test-videos name: e2e-test-videos
@@ -122,18 +130,26 @@ jobs:
# If e2e tests fail, Cypress creates a screenshot of what happened # If e2e tests fail, Cypress creates a screenshot of what happened
# Save those in an Artifact # Save those in an Artifact
- name: Upload e2e test failure screenshots to Artifacts - name: Upload e2e test failure screenshots to Artifacts
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: e2e-test-screenshots name: e2e-test-screenshots
path: cypress/screenshots path: cypress/screenshots
- name: Stop app (in case it stays up after e2e tests)
run: |
app_pid=$(lsof -t -i:4000)
if [[ ! -z $app_pid ]]; then
echo "App was still up! (PID: $app_pid)"
kill -9 $app_pid
fi
# Start up the app with SSR enabled (run in background) # Start up the app with SSR enabled (run in background)
- name: Start app in SSR (server-side rendering) mode - name: Start app in SSR (server-side rendering) mode
run: | run: |
nohup yarn run serve:ssr & nohup yarn run serve:ssr &
printf 'Waiting for app to start' printf 'Waiting for app to start'
until curl --output /dev/null --silent --head --fail http://localhost:4000/home; do until curl --output /dev/null --silent --head --fail http://127.0.0.1:4000/home; do
printf '.' printf '.'
sleep 2 sleep 2
done done
@@ -144,7 +160,7 @@ jobs:
# This step also prints entire HTML of homepage for easier debugging if grep fails. # This step also prints entire HTML of homepage for easier debugging if grep fails.
- name: Verify SSR (server-side rendering) - name: Verify SSR (server-side rendering)
run: | run: |
result=$(wget -O- -q http://localhost:4000/home) result=$(wget -O- -q http://127.0.0.1:4000/home)
echo "$result" echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace

49
.github/workflows/codescan.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
# DSpace CodeQL code scanning configuration for GitHub
# https://docs.github.com/en/code-security/code-scanning
#
# NOTE: Code scanning must be run separate from our default build.yml
# because CodeQL requires a fresh build with all tests *disabled*.
name: "Code Scanning"
# Run this code scan for all pushes / PRs to main branch. Also run once a week.
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
# Don't run if PR is only updating static documentation
paths-ignore:
- '**/*.md'
- '**/*.txt'
schedule:
- cron: "37 0 * * 1"
jobs:
analyze:
name: Analyze Code
runs-on: ubuntu-latest
# Limit permissions of this GitHub action. Can only write to security-events
permissions:
actions: read
contents: read
security-events: write
steps:
# https://github.com/actions/checkout
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
# https://github.com/github/codeql-action
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: javascript
# Autobuild attempts to build any compiled languages
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Perform GitHub Code Scanning.
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -12,6 +12,9 @@ on:
- 'dspace-**' - 'dspace-**'
pull_request: pull_request:
permissions:
contents: read # to fetch code (actions/checkout)
jobs: jobs:
docker: docker:
# Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular'
@@ -31,21 +34,29 @@ jobs:
# We turn off 'latest' tag by default. # We turn off 'latest' tag by default.
TAGS_FLAVOR: | TAGS_FLAVOR: |
latest=false 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: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout codebase - name: Checkout codebase
uses: actions/checkout@v2 uses: actions/checkout@v3
# https://github.com/docker/setup-buildx-action # https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx - 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
uses: docker/setup-qemu-action@v2
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Login to DockerHub - name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push # Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
@@ -57,7 +68,7 @@ jobs:
# Get Metadata for docker_build step below # Get Metadata for docker_build step below
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image
id: meta_build id: meta_build
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: dspace/dspace-angular images: dspace/dspace-angular
tags: ${{ env.IMAGE_TAGS }} tags: ${{ env.IMAGE_TAGS }}
@@ -66,10 +77,11 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push 'dspace-angular' image - name: Build and push 'dspace-angular' image
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build), # 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 # but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

View File

@@ -5,25 +5,22 @@ on:
issues: issues:
types: [opened] types: [opened]
permissions: {}
jobs: jobs:
automation: automation:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Add the new issue to a project board, if it needs triage # Add the new issue to a project board, if it needs triage
# See https://github.com/marketplace/actions/create-project-card-action # See https://github.com/actions/add-to-project
- name: Add issue to project board - name: Add issue to triage board
# Only add to project board if issue is flagged as "needs triage" or has no labels # 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 # 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) == '') 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. # 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 # 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) # This is necessary because the "DSpace Backlog" project is an org level project (i.e. not repo specific)
with: with:
GITHUB_TOKEN: ${{ secrets.ORG_PROJECT_TOKEN }} github-token: ${{ secrets.TRIAGE_PROJECT_TOKEN }}
PROJECT: DSpace Backlog project-url: https://github.com/orgs/DSpace/projects/24
COLUMN: Triage
CHECK_ORG_PROJECT: true
# Ignore errors
continue-on-error: true

View File

@@ -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. # NOTE: This means merge conflicts are only checked for when a PR is merged to main.
on: on:
push: push:
branches: branches: [ main ]
- 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: jobs:
triage: 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 runs-on: ubuntu-latest
permissions:
pull-requests: write
steps: 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 - 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. # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
# Note, the authentication token is created automatically # Note, the authentication token is created automatically
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
with: with:
CONFLICT_LABEL_NAME: 'merge conflict' conflict_label_name: 'merge conflict'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
# Ignore errors conflict_comment: |
continue-on-error: true 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!

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/.angular/cache
/__build__ /__build__
/__server_build__ /__server_build__
/node_modules /node_modules
@@ -36,3 +37,5 @@ package-lock.json
.env .env
/nbproject/ /nbproject/
junit.xml

46
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,46 @@
# How to Contribute
DSpace is a community built and supported project. We do not have a centralized development or support team, but have a dedicated group of volunteers who help us improve the software, documentation, resources, etc.
* [Contribute new code via a Pull Request](#contribute-new-code-via-a-pull-request)
* [Contribute documentation](#contribute-documentation)
* [Help others on mailing lists or Slack](#help-others-on-mailing-lists-or-slack)
* [Join a working or interest group](#join-a-working-or-interest-group)
## Contribute new code via a Pull Request
We accept [GitHub Pull Requests (PRs)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) at any time from anyone.
Contributors to each release are recognized in our [Release Notes](https://wiki.lyrasis.org/display/DSDOC7x/Release+Notes).
Code Contribution Checklist
- [ ] PRs _should_ be smaller in size (ideally less than 1,000 lines of code, not including comments & tests)
- [ ] PRs **must** pass [ESLint](https://eslint.org/) validation using `yarn lint`
- [ ] PRs **must** not introduce circular dependencies (verified via `yarn check-circ-deps`)
- [ ] PRs **must** include [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. Large or complex private methods should also have TypeDoc.
- [ ] PRs **must** pass all automated pecs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] If a PR includes new libraries/dependencies (in `package.json`), then their software licenses **must** align with the [DSpace BSD License](https://github.com/DSpace/dspace-angular/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
- [ ] Basic technical documentation _should_ be provided for any new features or configuration, either in the PR itself or in the DSpace Wiki documentation.
- [ ] If a PR fixes an issue ticket, please [link them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
Additional details on the code contribution process can be found in our [Code Contribution Guidelines](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines)
## Contribute documentation
DSpace Documentation is a collaborative effort in a shared Wiki. The latest documentation is at https://wiki.lyrasis.org/display/DSDOC7x
If you find areas of the DSpace Documentation which you wish to improve, please request a Wiki account by emailing wikihelp@lyrasis.org.
Once you have an account setup, contact @tdonohue (via [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) or email) for access to edit our Documentation.
## Help others on mailing lists or Slack
DSpace has our own [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) community and [Mailing Lists](https://wiki.lyrasis.org/display/DSPACE/Mailing+Lists) where discussions take place and questions are answered.
Anyone is welcome to join and help others. We just ask you to follow our [Code of Conduct](https://www.lyrasis.org/about/Pages/Code-of-Conduct.aspx) (adopted via LYRASIS).
## Join a working or interest group
Most of the work in building/improving DSpace comes via [Working Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Working+Groups) or [Interest Groups](https://wiki.lyrasis.org/display/DSPACE/DSpace+Interest+Groups).
All working/interest groups are open to anyone to join and participate. A few key groups to be aware of include:
* [DSpace 7 Working Group](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+Working+Group) - This is the main (mostly volunteer) development team. We meet weekly to review our current development [project board](https://github.com/orgs/DSpace/projects), assigning tickets and/or PRs.
* [DSpace Community Advisory Team (DCAT)](https://wiki.lyrasis.org/display/cmtygp/DSpace+Community+Advisory+Team) - This is an interest group for repository managers/administrators. We meet monthly to discuss DSpace, share tips & provide feedback back to developers.

View File

@@ -1,12 +1,21 @@
# This image will be published as dspace/dspace-angular # This image will be published as dspace/dspace-angular
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
FROM node:14-alpine FROM node:18-alpine
WORKDIR /app WORKDIR /app
ADD . /app/ ADD . /app/
EXPOSE 4000 EXPOSE 4000
# Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
# We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
# See, for example https://github.com/yarnpkg/yarn/issues/5540 # See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000 RUN yarn install --network-timeout 300000
CMD yarn run start:dev
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
# Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode
# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485
CMD yarn serve --host 0.0.0.0

View File

@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start 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) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`**
```bash ```bash
# clone the repo # clone the repo
@@ -90,7 +90,7 @@ Requirements
------------ ------------
- [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) - [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 `v16.x` or `v18.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. If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS.
@@ -101,7 +101,7 @@ Installing
### Configuring ### Configuring
Default configuration file is located in `config/` folder. Default runtime configuration file is located in `config/` folder. These configurations can be changed without rebuilding the distribution.
To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point. To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point.
@@ -167,6 +167,22 @@ These configuration sources are collected **at run time**, and written to `dist/
The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`. The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`.
#### Buildtime Configuring
Buildtime configuration must defined before build in order to include in transpiled JavaScript. This is primarily for the server. These settings can be found under `src/environment/` folder.
To override the default configuration values for development, create local file that override the build time parameters you need to change.
- Create a new `environment.(dev or development).ts` file in `src/environment/` for a `development` environment;
If needing to update default configurations values for production, update local file that override the build time parameters you need to change.
- Update `environment.production.ts` file in `src/environment/` for a `production` environment;
The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application.
> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap.
#### Using environment variables in code #### Using environment variables in code
To use environment variables in a UI component, use: To use environment variables in a UI component, use:
@@ -183,7 +199,6 @@ or
import { environment } from '../environment.ts'; import { environment } from '../environment.ts';
``` ```
Running the app Running the app
--------------- ---------------
@@ -193,7 +208,7 @@ After you have installed all dependencies you can now run the app. Run `yarn run
When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload. When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload.
To build the app for production and start the server run: To build the app for production and start the server (in one command) run:
```bash ```bash
yarn start yarn start
@@ -207,6 +222,10 @@ yarn run build:prod
``` ```
This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`. This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`.
After building the app for production, it can be started by running:
```bash
yarn run serve:ssr
```
### Running the application with Docker ### Running the application with Docker
NOTE: At this time, we do not have production-ready Docker images for DSpace. NOTE: At this time, we do not have production-ready Docker images for DSpace.
@@ -268,11 +287,29 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con
The test files can be found in the `./cypress/integration/` folder. The test files can be found in the `./cypress/integration/` folder.
Before you can run e2e tests, two things are required: Before you can run e2e tests, two things are REQUIRED:
1. You MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `config.prod.yml` or `config.yml`. You may override this using env variables, see [Configuring](#configuring). 1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time.
2. Your backend MUST include our Entities Test Data set. Some tests run against a (currently hardcoded) Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data * After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend.
* If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example:
```
DSPACE_REST_SSL = false
DSPACE_REST_HOST = localhost
DSPACE_REST_PORT = 8080
```
2. Your backend MUST include our [Entities Test Data set](https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data). Some tests run against a specific Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set.
* (Recommended) The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data
* Alternatively, the Entities Test Data set may be installed via a simple SQL import (e. g. `psql -U dspace < dspace7-entities-data.sql`). See instructions in link above.
Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results. After performing the above setup, you can run the e2e tests using
```
ng e2e
````
NOTE: By default these tests will run against the REST API backend configured via environment variables or in `config.prod.yml`. If you'd rather it use `config.dev.yml`, just set the NODE_ENV environment variable like this:
```
NODE_ENV=development ng e2e
```
The `ng e2e` command will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results.
#### Writing E2E Tests #### Writing E2E Tests
@@ -293,8 +330,11 @@ All E2E tests must be created under the `./cypress/integration/` folder, and mus
* In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. * 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. * 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 * 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 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. * 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. * 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.
@@ -311,7 +351,7 @@ Documentation
Official DSpace documentation is available in the DSpace wiki at https://wiki.lyrasis.org/display/DSDOC7x/ 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 ### Building code documentation
@@ -339,10 +379,10 @@ To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've
- [Sublime Text](http://www.sublimetext.com/3) - [Sublime Text](http://www.sublimetext.com/3)
- [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation) - [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation)
Collaborating Contributing
------------- -------------
See [the guide on the wiki](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+-+Angular+UI+Development#DSpace7-AngularUIDevelopment-Howtocontribute) See [Contributing documentation](CONTRIBUTING.md)
File Structure File Structure
-------------- --------------

View File

@@ -17,7 +17,6 @@
"build": { "build": {
"builder": "@angular-builders/custom-webpack:browser", "builder": "@angular-builders/custom-webpack:browser",
"options": { "options": {
"extractCss": true,
"preserveSymlinks": true, "preserveSymlinks": true,
"customWebpackConfig": { "customWebpackConfig": {
"path": "./webpack/webpack.browser.ts", "path": "./webpack/webpack.browser.ts",
@@ -26,12 +25,10 @@
} }
}, },
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"angular2-text-mask",
"cerialize", "cerialize",
"core-js", "core-js",
"lodash", "lodash",
"jwt-decode", "jwt-decode",
"url-parse",
"uuid", "uuid",
"webfontloader", "webfontloader",
"zone.js" "zone.js"
@@ -64,19 +61,31 @@
"bundleName": "dspace-theme" "bundleName": "dspace-theme"
} }
], ],
"scripts": [] "scripts": [],
"baseHref": "/"
}, },
"configurations": { "configurations": {
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts" "with": "src/environments/environment.production.ts"
},
{
"replace": "src/config/store/devtools.ts",
"with": "src/config/store/devtools.prod.ts"
} }
], ],
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"extractCss": true,
"namedChunks": false, "namedChunks": false,
"aot": true, "aot": true,
"extractLicenses": true, "extractLicenses": true,
@@ -104,6 +113,9 @@
"port": 4000 "port": 4000
}, },
"configurations": { "configurations": {
"development": {
"browserTarget": "dspace-angular:build:development"
},
"production": { "production": {
"browserTarget": "dspace-angular:build:production" "browserTarget": "dspace-angular:build:production"
} }
@@ -157,19 +169,6 @@
} }
} }
}, },
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"cypress/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": { "e2e": {
"builder": "@cypress/schematic:cypress", "builder": "@cypress/schematic:cypress",
"options": { "options": {
@@ -197,6 +196,10 @@
"tsConfig": "tsconfig.server.json" "tsConfig": "tsconfig.server.json"
}, },
"configurations": { "configurations": {
"development": {
"sourceMap": true,
"optimization": false
},
"production": { "production": {
"sourceMap": false, "sourceMap": false,
"optimization": true, "optimization": true,
@@ -204,6 +207,10 @@
{ {
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts" "with": "src/environments/environment.production.ts"
},
{
"replace": "src/config/store/devtools.ts",
"with": "src/config/store/devtools.prod.ts"
} }
] ]
} }
@@ -253,12 +260,22 @@
"watch": true, "watch": true,
"headless": false "headless": false
} }
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
} }
} }
} }
}, },
"defaultProject": "dspace-angular", "defaultProject": "dspace-angular",
"cli": { "cli": {
"analytics": false "analytics": false,
"defaultCollection": "@angular-eslint/schematics"
} }
} }

View File

@@ -2,7 +2,8 @@
debug: false debug: false
# Angular Universal server settings # 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: ui:
ssl: false ssl: false
host: localhost host: localhost
@@ -13,9 +14,12 @@ ui:
rateLimiter: rateLimiter:
windowMs: 60000 # 1 minute windowMs: 60000 # 1 minute
max: 500 # limit each IP to 500 requests per windowMs 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 # 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: rest:
ssl: true ssl: true
host: api7.dspace.org host: api7.dspace.org
@@ -51,6 +55,8 @@ auth:
# Form settings # Form settings
form: form:
# Sets the spellcheck textarea attribute value
spellCheck: true
# NOTE: Map server-side validators to comparative Angular form validators # NOTE: Map server-side validators to comparative Angular form validators
validatorMap: validatorMap:
required: required required: required
@@ -139,6 +145,9 @@ languages:
- code: nl - code: nl
label: Nederlands label: Nederlands
active: true active: true
- code: pl
label: Polski
active: true
- code: pt-PT - code: pt-PT
label: Português label: Português
active: true active: true
@@ -148,6 +157,28 @@ languages:
- code: fi - code: fi
label: Suomi label: Suomi
active: true 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
- code: uk
label: раї́нська
active: true
# Browse-By Pages # Browse-By Pages
browseBy: browseBy:
@@ -157,11 +188,39 @@ browseBy:
fiveYearLimit: 30 fiveYearLimit: 30
# The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900 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
# Item Page Config 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: item:
edit: edit:
undoTimeout: 10000 # 10 seconds undoTimeout: 10000 # 10 seconds
# Show the item access status label in items lists
showAccessStatuses: false
bitstream:
# Number of entries in the bitstream list in the item view page.
# Rounded to the nearest size in the list of selectable sizes on the
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
pageSize: 5
# Collection Page Config # Collection Page Config
collection: collection:
@@ -228,9 +287,26 @@ themes:
rel: manifest rel: manifest
href: assets/dspace/images/favicons/manifest.webmanifest href: assets/dspace/images/favicons/manifest.webmanifest
# The default bundles that should always be displayed as suggestions when you upload a new bundle
bundle:
standardBundles: [ ORIGINAL, THUMBNAIL, LICENSE ]
# Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video'). # Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video').
# For images, this enables a gallery viewer where you can zoom or page through images. # For images, this enables a gallery viewer where you can zoom or page through images.
# For videos, this enables embedded video streaming # For videos, this enables embedded video streaming
mediaViewer: mediaViewer:
image: false image: false
video: 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

View File

@@ -5,6 +5,21 @@
"screenshotsFolder": "cypress/screenshots", "screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts", "pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures", "fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4000", "baseUrl": "http://127.0.0.1:4000",
"retries": 2 "retries": {
} "runMode": 2,
"openMode": 0
},
"env": {
"DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com",
"DSPACE_TEST_ADMIN_PASSWORD": "dspace",
"DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4",
"DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200",
"DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067",
"DSPACE_TEST_SEARCH_TERM": "test",
"DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection",
"DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144",
"DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com",
"DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace"
}
}

1
cypress/.gitignore vendored
View File

@@ -1,2 +1,3 @@
screenshots/ screenshots/
videos/ videos/
downloads/

View File

@@ -16,8 +16,8 @@ describe('Homepage', () => {
it('should have a working search box', () => { it('should have a working search box', () => {
const queryString = 'test'; const queryString = 'test';
cy.get('ds-search-form input[name="query"]').type(queryString); cy.get('[data-test="search-box"]').type(queryString);
cy.get('ds-search-form button.search-button').click(); cy.get('[data-test="search-button"]').click();
cy.url().should('include', '/search'); cy.url().should('include', '/search');
cy.url().should('include', 'query=' + encodeURI(queryString)); cy.url().should('include', 'query=' + encodeURI(queryString));
}); });

View File

@@ -0,0 +1,126 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
const page = {
openLoginMenu() {
// Click the "Log In" dropdown menu in header
cy.get('ds-themed-navbar [data-test="login-menu"]').click();
},
openUserMenu() {
// Once logged in, click the User menu in header
cy.get('ds-themed-navbar [data-test="user-menu"]').click();
},
submitLoginAndPasswordByPressingButton(email, password) {
// Enter email
cy.get('ds-themed-navbar [data-test="email"]').type(email);
// Enter password
cy.get('ds-themed-navbar [data-test="password"]').type(password);
// Click login button
cy.get('ds-themed-navbar [data-test="login-button"]').click();
},
submitLoginAndPasswordByPressingEnter(email, password) {
// In opened Login modal, fill out email & password, then click Enter
cy.get('ds-themed-navbar [data-test="email"]').type(email);
cy.get('ds-themed-navbar [data-test="password"]').type(password);
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}');
},
submitLogoutByPressingButton() {
// This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button
cy.get('ds-themed-navbar [data-test="logout-button"]').click();
// Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes)
cy.wait('@logout');
}
};
describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => {
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
cy.visit(ENTITYPAGE);
// Login menu should exist
cy.get('ds-log-in').should('exist');
// Login, and the <ds-log-in> tag should no longer exist
page.openLoginMenu();
cy.get('.form-login').should('be.visible');
page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.get('ds-log-in').should('not.exist');
// Verify we are still on the same page
cy.url().should('include', ENTITYPAGE);
// Open user menu, verify user menu & logout button now available
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
cy.get('ds-log-out').should('be.visible');
});
it('should login when clicking enter key & stay on same page', () => {
cy.visit('/home');
// Open login menu in header & verify <ds-log-in> tag is visible
page.openLoginMenu();
cy.get('.form-login').should('be.visible');
// Login, and the <ds-log-in> tag should no longer exist
page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.get('.form-login').should('not.exist');
// Verify we are still on homepage
cy.url().should('include', '/home');
// Open user menu, verify user menu & logout button now available
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
cy.get('ds-log-out').should('be.visible');
});
it('should support logout', () => {
// First authenticate & access homepage
cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD);
cy.visit('/');
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
cy.get('ds-log-in').should('not.exist');
cy.get('ds-log-out').should('exist');
// Click logout button
page.openUserMenu();
page.submitLogoutByPressingButton();
// Verify ds-log-in tag now exists
cy.get('ds-log-in').should('exist');
cy.get('ds-log-out').should('not.exist');
});
it('should allow new user registration', () => {
cy.visit('/');
page.openLoginMenu();
// Registration link should be visible
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible');
// Click registration link & you should go to registration page
cy.get('ds-themed-navbar [data-test="register"]').click();
cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist');
});
it('should allow forgot password', () => {
cy.visit('/');
page.openLoginMenu();
// Forgot password link should be visible
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible');
// Click link & you should go to Forgot Password page
cy.get('ds-themed-navbar [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist');
});
});

View File

@@ -0,0 +1,155 @@
import { Options } from 'cypress-axe';
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('My DSpace page', () => {
it('should display recent submissions and pass accessibility tests', () => {
cy.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
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('.filter-toggle').click({ multiple: true });
// Analyze <ds-my-dspace-page> for accessibility issues
testA11y(
{
include: ['ds-my-dspace-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
});
it('should have a working detailed view that passes accessibility tests', () => {
cy.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
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
cy.get('ds-object-detail').should('exist');
// Analyze <ds-search-page> for accessibility issues
testA11y('ds-my-dspace-page',
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
});
// NOTE: Deleting existing submissions is exercised by submission.spec.ts
it('should let you start a new submission & edit in-progress submissions', () => {
cy.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
cy.get('#entityControlsDropdownMenu button[title="none"]').click();
// This should display the <ds-create-item-parent-selector> (popup window)
cy.get('ds-create-item-parent-selector').should('be.visible');
// Type in a known Collection name in the search box
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
// Click on the button matching that known Collection name
cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click();
// New URL should include /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems');
// The Submission edit form tag should be visible
cy.get('ds-submission-edit').should('be.visible');
// A Collection menu button should exist & its value should be the selected collection
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME);
// Now that we've created a submission, we'll test that we can go back and Edit it.
// Get our Submission URL, to parse out the ID of this new submission
cy.location().then(fullUrl => {
// This will be the full path (/workspaceitems/[id]/edit)
const path = fullUrl.pathname;
// Split on the slashes
const subpaths = path.split('/');
// Part 2 will be the [id] of the submission
const id = subpaths[2];
// Click the "Save for Later" button to save this submission
cy.get('ds-submission-form-footer [data-test="save-for-later"]').click();
// "Save for Later" should send us to MyDSpace
cy.url().should('include', '/mydspace');
// Close any open notifications, to make sure they don't get in the way of next steps
cy.get('[data-dismiss="alert"]').click({multiple: true});
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// On MyDSpace, find the submission we just created via its ID
cy.get('[data-test="search-box"]').type(id);
cy.get('[data-test="search-button"]').click();
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// Click the Edit button for this in-progress submission
cy.get('#edit_' + id).click();
// Should send us back to the submission form
cy.url().should('include', '/workspaceitems/' + id + '/edit');
// Discard our new submission by clicking Discard in Submission form & confirming
cy.get('ds-submission-form-footer [data-test="discard"]').click();
cy.get('button#discard_submit').click();
// Discarding should send us back to MyDSpace
cy.url().should('include', '/mydspace');
});
});
it('should let you import from external sources', () => {
cy.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
cy.get('#importControlsDropdownMenu button[title="none"]').click();
// New URL should include /import-external, as we've moved to the import page
cy.url().should('include', '/import-external');
// The external import searchbox should be visible
cy.get('ds-submission-import-external-searchbar').should('be.visible');
});
});

View File

@@ -1,49 +1,66 @@
import { TEST_SEARCH_TERM } from 'cypress/support';
const page = { const page = {
fillOutQueryInNavBar(query) { fillOutQueryInNavBar(query) {
// Click the magnifying glass // Click the magnifying glass
cy.get('.navbar-container #search-navbar-container form a').click(); cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
// Fill out a query in input that appears // Fill out a query in input that appears
cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type(query); cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query);
}, },
submitQueryByPressingEnter() { submitQueryByPressingEnter() {
cy.get('.navbar-container #search-navbar-container form input[name = "query"]').type('{enter}'); cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}');
}, },
submitQueryByPressingIcon() { submitQueryByPressingIcon() {
cy.get('.navbar-container #search-navbar-container form .submit-icon').click(); cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
} }
}; };
describe('Search from Navigation Bar', () => { describe('Search from Navigation Bar', () => {
// NOTE: these tests currently assume this query will return results! // NOTE: these tests currently assume this query will return results!
const query = 'test'; const query = TEST_SEARCH_TERM;
it('should go to search page with correct query if submitted (from home)', () => { it('should go to search page with correct query if submitted (from home)', () => {
cy.visit('/'); cy.visit('/');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter(); page.submitQueryByPressingEnter();
// New URL should include query param // New URL should include query param
cy.url().should('include', 'query=' + query); cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
cy.get('ds-item-search-result-list-element').should('be.visible'); cy.get('[data-test="list-object"]').should('be.visible');
}); });
it('should go to search page with correct query if submitted (from search)', () => { it('should go to search page with correct query if submitted (from search)', () => {
cy.visit('/search'); cy.visit('/search');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingEnter(); page.submitQueryByPressingEnter();
// New URL should include query param // New URL should include query param
cy.url().should('include', 'query=' + query); cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
cy.get('ds-item-search-result-list-element').should('be.visible'); cy.get('[data-test="list-object"]').should('be.visible');
}); });
it('should allow user to also submit query by clicking icon', () => { it('should allow user to also submit query by clicking icon', () => {
cy.visit('/'); cy.visit('/');
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// Run the search
page.fillOutQueryInNavBar(query); page.fillOutQueryInNavBar(query);
page.submitQueryByPressingIcon(); page.submitQueryByPressingIcon();
// New URL should include query param // New URL should include query param
cy.url().should('include', 'query=' + query); cy.url().should('include', 'query=' + query);
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
cy.get('ds-item-search-result-list-element').should('be.visible'); cy.get('[data-test="list-object"]').should('be.visible');
}); });
}); });

View File

@@ -1,34 +1,30 @@
import { Options } from 'cypress-axe'; import { Options } from 'cypress-axe';
import { TEST_SEARCH_TERM } from 'cypress/support';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => { describe('Search Page', () => {
// unique ID of the search form (for selecting specific elements below)
const SEARCHFORM_ID = '#search-form';
it('should contain query value when navigating to page with query parameter', () => {
const queryString = 'test query';
cy.visit('/search?query=' + queryString);
cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString);
});
it('should redirect to the correct url when query was set and submit button was triggered', () => { it('should redirect to the correct url when query was set and submit button was triggered', () => {
const queryString = 'Another interesting query string'; const queryString = 'Another interesting query string';
cy.visit('/search'); cy.visit('/search');
// Type query in searchbox & click search button // Type query in searchbox & click search button
cy.get(SEARCHFORM_ID + ' input[name="query"]').type(queryString); cy.get('[data-test="search-box"]').type(queryString);
cy.get(SEARCHFORM_ID + ' button.search-button').click(); cy.get('[data-test="search-button"]').click();
cy.url().should('include', 'query=' + encodeURI(queryString)); cy.url().should('include', 'query=' + encodeURI(queryString));
}); });
it('should pass accessibility tests', () => { it('should load results and pass accessibility tests', () => {
cy.visit('/search'); cy.visit('/search?query=' + TEST_SEARCH_TERM);
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
// <ds-search-page> tag must be loaded // <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist'); cy.get('ds-search-page').should('exist');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter // Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well) // (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 <ds-search-page> for accessibility issues // Analyze <ds-search-page> for accessibility issues
testA11y( testA11y(
@@ -48,16 +44,18 @@ describe('Search Page', () => {
); );
}); });
it('should pass accessibility tests in Grid view', () => { it('should have a working grid view that passes accessibility tests', () => {
cy.visit('/search'); cy.visit('/search?query=' + TEST_SEARCH_TERM);
// Click to display grid view // Click button in sidebar to display grid view
// TODO: These buttons should likely have an easier way to uniquely select cy.get('ds-search-sidebar [data-test="grid-view"]').click();
cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?view=grid"] > .fas').click();
// <ds-search-page> tag must be loaded // <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist'); cy.get('ds-search-page').should('exist');
// At least one grid object (card) should be displayed
cy.get('[data-test="grid-object"]').should('be.visible');
// Analyze <ds-search-page> for accessibility issues // Analyze <ds-search-page> for accessibility issues
testA11y('ds-search-page', testA11y('ds-search-page',
{ {

View File

@@ -0,0 +1,138 @@
import { Options } from 'cypress-axe';
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('New Submission page', () => {
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
it('should create a new submission when using /submit path & pass accessibility', () => {
// 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');
// The Submission edit form tag should be visible
cy.get('ds-submission-edit').should('be.visible');
// A Collection menu button should exist & it's value should be the selected collection
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME);
// 4 sections should be visible by default
cy.get('div#section_traditionalpageone').should('be.visible');
cy.get('div#section_traditionalpagetwo').should('be.visible');
cy.get('div#section_upload').should('be.visible');
cy.get('div#section_license').should('be.visible');
// Discard button should work
// Clicking it will display a confirmation, which we will confirm with another click
cy.get('button#discard').click();
cy.get('button#discard_submit').click();
});
it('should block submission & show errors if required fields are missing', () => {
// 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();
// A warning alert should display.
cy.get('ds-notification div.alert-success').should('not.exist');
cy.get('ds-notification div.alert-warning').should('be.visible');
// First section should have an exclamation error in the header
// (as it has required fields)
cy.get('div#traditionalpageone-header i.fa-exclamation-circle').should('be.visible');
// Title field should have class "is-invalid" applied, as it's required
cy.get('input#dc_title').should('have.class', 'is-invalid');
// Date Year field should also have "is-valid" class
cy.get('input#dc_date_issued_year').should('have.class', 'is-invalid');
// FINALLY, cleanup after ourselves. This also exercises the MyDSpace delete button.
// Get our Submission URL, to parse out the ID of this submission
cy.location().then(fullUrl => {
// This will be the full path (/workspaceitems/[id]/edit)
const path = fullUrl.pathname;
// Split on the slashes
const subpaths = path.split('/');
// Part 2 will be the [id] of the submission
const id = subpaths[2];
// Even though form is incomplete, the "Save for Later" button should still work
cy.get('button#saveForLater').click();
// "Save for Later" should send us to MyDSpace
cy.url().should('include', '/mydspace');
// A success alert should be visible
cy.get('ds-notification div.alert-success').should('be.visible');
// Now, dismiss any open alert boxes (may be multiple, as tests run quickly)
cy.get('[data-dismiss="alert"]').click({multiple: true});
// This is the GET command that will actually run the search
cy.intercept('GET', '/server/api/discover/search/objects*').as('search-results');
// On MyDSpace, find the submission we just saved via its ID
cy.get('[data-test="search-box"]').type(id);
cy.get('[data-test="search-button"]').click();
// Wait for search results to come back from the above GET command
cy.wait('@search-results');
// Delete our created submission & confirm deletion
cy.get('button#delete_' + id).click();
cy.get('button#delete_confirm').click();
});
});
it('should allow for deposit if all required fields completed & file uploaded', () => {
// 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');
// Confirm the required license by checking checkbox
// (NOTE: requires "force:true" cause Cypress claims this checkbox is covered by its own <span>)
cy.get('input#granted').check( {force: true} );
// Before using Cypress drag & drop, we have to manually trigger the "dragover" event.
// This ensures our UI displays the dropzone that covers the entire submission page.
// (For some reason Cypress drag & drop doesn't trigger this even itself & upload won't work without this trigger)
cy.get('ds-uploader').trigger('dragover');
// This is the POST command that will upload the file
cy.intercept('POST', '/server/api/submission/workspaceitems/*').as('upload');
// Upload our DSpace logo via drag & drop onto submission form
// cy.get('div#section_upload')
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', {
action: 'drag-drop'
});
// Wait for upload to complete before proceeding
cy.wait('@upload');
// Close the upload success notice
cy.get('[data-dismiss="alert"]').click({multiple: true});
// Wait for deposit button to not be disabled & click it.
cy.get('button#deposit').should('not.be.disabled').click();
// No warnings should exist. Instead, just successful deposit alert is displayed
cy.get('ds-notification div.alert-warning').should('not.exist');
cy.get('ds-notification div.alert-success').should('be.visible');
});
});

View File

@@ -1,15 +1,34 @@
const fs = require('fs');
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api // For more info, visit https://on.cypress.io/plugins-api
module.exports = (on, config) => { module.exports = (on, config) => {
// Define "log" and "table" tasks, used for logging accessibility errors during CI
// Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file
on('task', { on('task', {
// Define "log" and "table" tasks, used for logging accessibility errors during CI
// Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file
log(message: string) { log(message: string) {
console.log(message); console.log(message);
return null; return null;
}, },
table(message: string) { table(message: string) {
console.table(message); console.table(message);
return null;
},
// Cypress doesn't have access to the running application in Node.js.
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
// Instead, we'll read our running application's config.json, which contains the configs &
// is regenerated at runtime each time the Angular UI application starts up.
readUIConfig() {
// Check if we have a config.json in the src/assets. If so, use that.
// This is where it's written when running "ng e2e" or "yarn serve"
if (fs.existsSync('./src/assets/config.json')) {
return fs.readFileSync('./src/assets/config.json', 'utf8');
// Otherwise, check the dist/browser/assets
// This is where it's written when running "serve:ssr", which is what CI uses to start the frontend
} else if (fs.existsSync('./dist/browser/assets/config.json')) {
return fs.readFileSync('./dist/browser/assets/config.json', 'utf8');
}
return null; return null;
} }
}); });

View File

@@ -1,43 +1,110 @@
// *********************************************** // ***********************************************
// This example namespace declaration will help // This File is for Custom Cypress commands.
// with Intellisense and code completion in your // See docs at https://docs.cypress.io/api/cypress-api/custom-commands
// IDE or Text Editor.
// *********************************************** // ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> { import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
// customCommand(param: any): typeof customCommand; import { FALLBACK_TEST_REST_BASE_URL } from '.';
// }
// } // Declare Cypress namespace to help with Intellisense & code completion in IDEs
// // ALL custom commands MUST be listed here for code completion to work
// function customCommand(param: any): void { // tslint:disable-next-line:no-namespace
// console.warn(param); declare global {
// } namespace Cypress {
// interface Chainable<Subject = any> {
// NOTE: You can use it like so: /**
// Cypress.Commands.add('customCommand', customCommand); * Login to backend before accessing the next page. Ensures that the next
// * call to "cy.visit()" will be authenticated as this user.
// *********************************************** * @param email email to login as
// This example commands.js shows you how to * @param password password to login as
// create various custom commands and overwrite */
// existing commands. login(email: string, password: string): typeof login;
//
// For more comprehensive examples of custom /**
// commands please read more here: * Login via form before accessing the next page. Useful to fill out login
// https://on.cypress.io/custom-commands * 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
// */
// -- This is a parent command -- loginViaForm(email: string, password: string): typeof loginViaForm;
// Cypress.Commands.add("login", (email, password) => { ... }) }
// }
// }
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) /**
// * Login user via REST API directly, and pass authentication token to UI via
// * the UI's dsAuthInfo cookie.
// -- This is a dual command -- * WARNING: WHILE THIS METHOD WORKS, OCCASIONALLY RANDOM AUTHENTICATION ERRORS OCCUR.
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) * At this time "loginViaForm()" seems more consistent/stable.
// * @param email email to login as
// * @param password password to login as
// -- This will overwrite an existing command -- */
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) function login(email: string, password: string): void {
// Cypress doesn't have access to the running application in Node.js.
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
// Instead, we'll read our running application's config.json, which contains the configs &
// is regenerated at runtime each time the Angular UI application starts up.
cy.task('readUIConfig').then((str: string) => {
// Parse config into a JSON object
const config = JSON.parse(str);
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl);
baseRestUrl = config.rest.baseUrl;
}
// To login via REST, first we have to do a GET to obtain a valid CSRF token
cy.request( baseRestUrl + '/api/authn/status' )
.then((response) => {
// We should receive a CSRF token returned in a response header
expect(response.headers).to.have.property('dspace-xsrf-token');
const csrfToken = response.headers['dspace-xsrf-token'];
// Now, send login POST request including that CSRF token
cy.request({
method: 'POST',
url: baseRestUrl + '/api/authn/login',
headers: { 'X-XSRF-TOKEN' : csrfToken},
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);
// We expect to have a valid authorization header returned (with our auth token)
expect(resp.headers).to.have.property('authorization');
// Initialize our AuthTokenInfo object from the authorization header.
const authheader = resp.headers.authorization as string;
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie
// This ensures the UI will recognize we are logged in on next "visit()"
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
});
});
});
}
// Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login);
/**
* 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);

View File

@@ -13,14 +13,51 @@
// https://on.cypress.io/configuration // https://on.cypress.io/configuration
// *********************************************************** // ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax // Import all custom Commands (from commands.ts) for all tests
// import './commands'; import './commands';
// Import Cypress Axe tools for all tests // Import Cypress Axe tools for all tests
// https://github.com/component-driven/cypress-axe // https://github.com/component-driven/cypress-axe
import 'cypress-axe'; import 'cypress-axe';
// Runs once before the first test in each "block"
beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
// This just ensures it doesn't get in the way of matching other objects in the page.
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
});
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test.
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test.
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
afterEach(() => {
cy.window().then((win) => {
win.location.href = 'about:blank';
});
});
// Global constants used in tests // Global constants used in tests
export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200'; // May be overridden in our cypress.json config file using specified environment variables.
export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4'; // Default values listed here are all valid for the Demo Entities Data set available at
export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment)
// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
// Admin account used for administrative tests
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
// Community/collection/publication used for view/edit tests
export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200';
export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4';
export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
// Search term (should return results) used in search tests
export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test';
// Collection used for submission tests
export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection';
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';

View File

@@ -1,93 +1,95 @@
# Docker Compose files # 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 ***
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
## 'Dockerfile' in root directory
``` This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
docker build -t dspace/dspace-angular:dspace-7_x .
``` ```
docker build -t dspace/dspace-angular:dspace-7_x .
This image is built *automatically* after each commit is made to the `main` branch. ```
Admins to our DockerHub repo can manually publish with the following command. This image is built *automatically* after each commit is made to the `main` branch.
```
docker push dspace/dspace-angular:dspace-7_x Admins to our DockerHub repo can manually publish with the following command.
``` ```
docker push dspace/dspace-angular:dspace-7_x
## docker directory ```
- docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. ## docker directory
- docker-compose-rest.yml - docker-compose.yml
- Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
- docker-compose-ci.yml - docker-compose-rest.yml
- Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes
- cli.yml - docker-compose-ci.yml
- Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup.
- cli.assetstore.yml - cli.yml
- Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container.
- cli.assetstore.yml
- Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing.
## To refresh / pull DSpace images from Dockerhub
```
docker-compose -f docker/docker-compose.yml pull ## To refresh / pull DSpace images from Dockerhub
``` ```
docker-compose -f docker/docker-compose.yml pull
## To build DSpace images using code in your branch ```
```
docker-compose -f docker/docker-compose.yml build ## To build DSpace images using code in your branch
``` ```
docker-compose -f docker/docker-compose.yml build
## To start DSpace (REST and Angular) from your branch ```
``` ## To start DSpace (REST and Angular) from your branch
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
``` ```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
## Run DSpace REST and DSpace Angular from local branches. ```
_The system will be started in 2 steps. Each step shares the same docker network._
## Run DSpace REST and DSpace Angular from local branches.
From DSpace/DSpace (build as needed) _The system will be started in 2 steps. Each step shares the same docker network._
```
docker-compose -p d7 up -d From DSpace/DSpace (build as needed)
``` ```
docker-compose -p d7 up -d
From DSpace/DSpace-angular ```
```
docker-compose -p d7 -f docker/docker-compose.yml up -d From DSpace/DSpace-angular
``` ```
docker-compose -p d7 -f docker/docker-compose.yml up -d
## Ingest test data from AIPDIR ```
Create an administrator ## Ingest test data from AIPDIR
```
docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en Create an administrator
``` ```
docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
Load content from AIP files ```
```
docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli Load content from AIP files
``` ```
docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
## Alternative Ingest - Use Entities dataset ```
_Delete your docker volumes or use a unique project (-p) name_
## Alternative Ingest - Use Entities dataset
Start DSpace with Database Content from a database dump _Delete your docker volumes or use a unique project (-p) name_
```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d Start DSpace with Database Content from a database dump
``` ```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
Load assetstore content and trigger a re-index of the repository ```
```
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli Load assetstore content and trigger a re-index of the repository
``` ```
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
## End to end testing of the rest api (runs in travis). ```
_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
## End to end testing of the rest api (runs in travis).
``` _In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
``` ```
docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
```

View File

@@ -35,6 +35,6 @@ services:
tar xvfz /tmp/assetstore.tar.gz tar xvfz /tmp/assetstore.tar.gz
fi fi
/dspace/bin/dspace index-discovery /dspace/bin/dspace index-discovery -b
/dspace/bin/dspace oai import /dspace/bin/dspace oai import
/dspace/bin/dspace oai clean-cache /dspace/bin/dspace oai clean-cache

View File

@@ -20,12 +20,12 @@ services:
environment: environment:
# This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This LOADSQL should be kept in sync with the URL in DSpace/DSpace
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
- LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql
dspace: dspace:
### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' ####
# Ensure that the database is ready BEFORE starting tomcat # Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 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 # 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: # 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 # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49
@@ -35,7 +35,7 @@ services:
- '-c' - '-c'
- | - |
while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done; while (!</dev/tcp/dspacedb/5432) > /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 <name-map collection-handle="123456789/3" submission-name="Publication"/> \ sed -i '/name-map collection-handle="default".*/a \\n <name-map collection-handle="123456789/3" submission-name="Publication"/> \
<name-map collection-handle="123456789/4" submission-name="Publication"/> \ <name-map collection-handle="123456789/4" submission-name="Publication"/> \
<name-map collection-handle="123456789/281" submission-name="Publication"/> \ <name-map collection-handle="123456789/281" submission-name="Publication"/> \

View File

@@ -24,8 +24,8 @@ services:
# __D__ => "-" (e.g. google__D__metadata => google-metadata) # __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir, dspace.server.url and dspace.ui.url # dspace.dir, dspace.server.url and dspace.ui.url
dspace__P__dir: /dspace dspace__P__dir: /dspace
dspace__P__server__P__url: http://localhost:8080/server dspace__P__server__P__url: http://127.0.0.1:8080/server
dspace__P__ui__P__url: http://localhost:4000 dspace__P__ui__P__url: http://127.0.0.1:4000
# db.url: Ensure we are using the 'dspacedb' image for our database # db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
# solr.server: Ensure we are using the 'dspacesolr' image for Solr # solr.server: Ensure we are using the 'dspacesolr' image for Solr
@@ -46,14 +46,14 @@ services:
- solr_configs:/dspace/solr - solr_configs:/dspace/solr
# Ensure that the database is ready BEFORE starting tomcat # Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 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 # 3. Finally, start Tomcat
entrypoint: entrypoint:
- /bin/bash - /bin/bash
- '-c' - '-c'
- | - |
while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done; while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done;
/dspace/bin/dspace database migrate /dspace/bin/dspace database migrate ignored
catalina.sh run catalina.sh run
# DSpace database container # DSpace database container
# NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data
@@ -63,7 +63,7 @@ services:
# This LOADSQL should be kept in sync with the LOADSQL in # This LOADSQL should be kept in sync with the LOADSQL in
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-2021-04-14.sql LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql
PGDATA: /pgdata PGDATA: /pgdata
image: dspace/dspace-postgres-pgcrypto:loadsql image: dspace/dspace-postgres-pgcrypto:loadsql
networks: networks:

View File

@@ -22,7 +22,7 @@ module.exports = function (config) {
reports: ['html', 'lcovonly', 'text-summary'], reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true fixWebpackSourcePaths: true
}, },
reporters: ['mocha', 'kjhtml'], reporters: ['mocha', 'kjhtml', 'coverage-istanbul'],
mochaReporter: { mochaReporter: {
ignoreSkipped: true, ignoreSkipped: true,
output: 'autowatch' output: 'autowatch'

View File

@@ -1,6 +1,6 @@
{ {
"name": "dspace-angular", "name": "dspace-angular",
"version": "0.0.0", "version": "7.5.0-next",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"config:watch": "nodemon", "config:watch": "nodemon",
@@ -9,10 +9,11 @@
"start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", "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: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", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"preserve": "yarn base-href",
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"serve:ssr": "node dist/server/main", "serve:ssr": "node dist/server/main",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build", "build": "ng build --configuration development",
"build:stats": "ng build --stats-json", "build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr", "build:prod": "yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
@@ -36,7 +37,9 @@
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:run": "cypress run", "cypress:run": "cypress run",
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts" "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": { "browser": {
"fs": false, "fs": false,
@@ -47,147 +50,151 @@
"private": true, "private": true,
"resolutions": { "resolutions": {
"minimist": "^1.2.5", "minimist": "^1.2.5",
"webdriver-manager": "^12.1.8" "webdriver-manager": "^12.1.8",
"ts-node": "10.2.1"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "~11.2.14", "@angular/animations": "~13.3.12",
"@angular/cdk": "^11.2.13", "@angular/cdk": "^13.2.6",
"@angular/common": "~11.2.14", "@angular/common": "~13.3.12",
"@angular/compiler": "~11.2.14", "@angular/compiler": "~13.3.12",
"@angular/core": "~11.2.14", "@angular/core": "~13.3.12",
"@angular/forms": "~11.2.14", "@angular/forms": "~13.3.12",
"@angular/localize": "11.2.14", "@angular/localize": "13.3.12",
"@angular/platform-browser": "~11.2.14", "@angular/platform-browser": "~13.3.12",
"@angular/platform-browser-dynamic": "~11.2.14", "@angular/platform-browser-dynamic": "~13.3.12",
"@angular/platform-server": "~11.2.14", "@angular/platform-server": "~13.3.12",
"@angular/router": "~11.2.14", "@angular/router": "~13.3.12",
"@kolkov/ngx-gallery": "^1.2.3", "@babel/runtime": "7.17.2",
"@ng-bootstrap/ng-bootstrap": "9.1.3", "@kolkov/ngx-gallery": "^2.0.1",
"@ng-dynamic-forms/core": "^13.0.0", "@material-ui/core": "^4.11.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^13.0.0", "@material-ui/icons": "^4.9.1",
"@ngrx/effects": "^11.1.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ngrx/router-store": "^11.1.1", "@ng-dynamic-forms/core": "^15.0.0",
"@ngrx/store": "^11.1.1", "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
"@nguniversal/express-engine": "11.2.1", "@ngrx/effects": "^13.0.2",
"@ngrx/router-store": "^13.0.2",
"@ngrx/store": "^13.0.2",
"@nguniversal/express-engine": "^13.0.2",
"@ngx-translate/core": "^13.0.0", "@ngx-translate/core": "^13.0.0",
"@nicky-lenaers/ngx-scroll-to": "^9.0.0", "@nicky-lenaers/ngx-scroll-to": "^13.0.0",
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0", "angular-idle-preload": "3.0.0",
"angular2-text-mask": "9.0.0", "angulartics2": "^12.0.0",
"angulartics2": "^10.0.0", "axios": "^0.27.2",
"bootstrap": "4.3.1", "bootstrap": "^4.6.1",
"caniuse-lite": "^1.0.30001165",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.8.0", "cli-progress": "^3.8.0",
"colors": "^1.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cookie-parser": "1.4.5", "cookie-parser": "1.4.5",
"core-js": "^3.7.0", "core-js": "^3.7.0",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"express": "^4.17.1", "express": "^4.17.1",
"express-rate-limit": "^5.1.3", "express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.0.0-1", "fast-json-patch": "^3.0.0-1",
"file-saver": "^2.0.5",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"font-awesome": "4.7.0",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^1.0.5",
"https": "1.0.0",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json5": "^2.1.3", "json5": "^2.2.2",
"jsonschema": "1.4.0", "jsonschema": "1.4.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"klaro": "^0.7.10", "klaro": "^0.7.18",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-mathjax3": "^4.3.1",
"mirador": "^3.3.0", "mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0", "mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0", "mirador-share-plugin": "^0.11.0",
"moment": "^2.29.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ng-mocks": "11.11.2", "ng-mocks": "^13.1.1",
"ng2-file-upload": "1.4.0", "ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.3", "ng2-nouislider": "^1.8.3",
"ngx-bootstrap": "7.1.2", "ngx-bootstrap": "7.1.2",
"ngx-infinite-scroll": "^10.0.1", "ngx-infinite-scroll": "^10.0.1",
"ngx-moment": "^5.0.0",
"ngx-pagination": "5.0.0", "ngx-pagination": "5.0.0",
"ngx-sortablejs": "^11.1.0", "ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^13.0.2",
"nouislider": "^14.6.3", "nouislider": "^14.6.3",
"pem": "1.14.4", "pem": "1.14.4",
"postcss-cli": "^8.3.0", "prop-types": "^15.7.2",
"react-copy-to-clipboard": "^5.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^6.6.3", "rxjs": "^7.5.5",
"sanitize-html": "^2.7.2",
"sortablejs": "1.13.0", "sortablejs": "1.13.0",
"tslib": "^2.0.0",
"url-parse": "^1.5.3",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webfontloader": "1.6.28", "webfontloader": "1.6.28",
"zone.js": "^0.10.3" "zone.js": "~0.11.5"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "10.0.1", "@angular-builders/custom-webpack": "~13.1.0",
"@angular-devkit/build-angular": "~0.1102.15", "@angular-devkit/build-angular": "~13.3.10",
"@angular/cli": "~11.2.15", "@angular-eslint/builder": "13.1.0",
"@angular/compiler-cli": "~11.2.14", "@angular-eslint/eslint-plugin": "13.1.0",
"@angular/language-service": "~11.2.14", "@angular-eslint/eslint-plugin-template": "13.1.0",
"@angular-eslint/schematics": "13.1.0",
"@angular-eslint/template-parser": "13.1.0",
"@angular/cli": "~13.3.10",
"@angular/compiler-cli": "~13.3.12",
"@angular/language-service": "~13.3.12",
"@cypress/schematic": "^1.5.0", "@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^5.5.0", "@fortawesome/fontawesome-free": "^6.2.1",
"@ngrx/store-devtools": "^11.1.1", "@ngrx/store-devtools": "^13.0.2",
"@ngtools/webpack": "10.2.3", "@ngtools/webpack": "^13.2.6",
"@nguniversal/builders": "~11.2.1", "@nguniversal/builders": "^13.1.1",
"@types/deep-freeze": "0.1.2", "@types/deep-freeze": "0.1.2",
"@types/express": "^4.17.9", "@types/express": "^4.17.9",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/jasminewd2": "~2.0.8",
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.165", "@types/lodash": "^4.14.165",
"@types/node": "^14.14.9", "@types/node": "^14.14.9",
"axe-core": "^4.3.3", "@types/sanitize-html": "^2.6.2",
"codelyzer": "^6.0.0", "@typescript-eslint/eslint-plugin": "5.11.0",
"compression-webpack-plugin": "^3.0.1", "@typescript-eslint/parser": "5.11.0",
"axe-core": "^4.4.3",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "3.4.0", "cypress": "9.7.0",
"cssnano": "^4.1.10", "cypress-axe": "^0.14.0",
"cypress": "8.6.0",
"cypress-axe": "^0.13.0",
"debug-loader": "^0.0.1",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"dotenv": "^8.2.0", "eslint": "^8.2.0",
"fork-ts-checker-webpack-plugin": "^6.0.3", "eslint-plugin-deprecation": "^1.3.2",
"html-loader": "^1.3.2", "eslint-plugin-import": "^2.25.4",
"html-webpack-plugin": "^4.5.0", "eslint-plugin-jsdoc": "^39.6.4",
"jasmine-core": "~3.6.0", "eslint-plugin-lodash": "^7.4.0",
"jasmine-marbles": "0.6.0", "eslint-plugin-unused-imports": "^2.0.0",
"jasmine-spec-reporter": "~5.0.0", "express-static-gzip": "^2.1.5",
"karma": "^5.2.3", "jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
"karma": "^6.3.14",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"nodemon": "^2.0.15", "ngx-mask": "^13.1.7",
"optimize-css-assets-webpack-plugin": "^5.0.4", "nodemon": "^2.0.20",
"postcss-apply": "0.11.0", "postcss": "^8.1",
"postcss-import": "^12.0.1", "postcss-apply": "0.12.0",
"postcss-loader": "^3.0.0", "postcss-import": "^14.0.0",
"postcss-preset-env": "6.7.0", "postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2",
"postcss-responsive-type": "1.0.0", "postcss-responsive-type": "1.0.0",
"protractor": "^7.0.0",
"protractor-istanbul-plugin": "2.0.0",
"raw-loader": "0.5.1",
"react": "^16.14.0", "react": "^16.14.0",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs-spy": "^7.5.3", "rxjs-spy": "^8.0.2",
"sass": "~1.33.0",
"sass-loader": "^12.6.0",
"sass-resources-loader": "^2.1.1", "sass-resources-loader": "^2.1.1",
"script-ext-html-webpack-plugin": "2.1.5",
"string-replace-loader": "^2.3.0",
"terser-webpack-plugin": "^2.3.1",
"ts-loader": "^5.2.0",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"tslint": "^6.1.3", "typescript": "~4.5.5",
"typescript": "~4.0.5", "webpack": "^5.69.1",
"webpack": "^4.44.2",
"webpack-bundle-analyzer": "^4.4.0", "webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^4.2.0", "webpack-cli": "^4.2.0",
"webpack-dev-server": "^4.5.0" "webpack-dev-server": "^4.5.0"

36
scripts/base-href.ts Normal file
View File

@@ -0,0 +1,36 @@
import { existsSync, writeFileSync } 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 (!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;
writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n');
} catch (e) {
console.error(e);
}

View File

@@ -1,5 +1,5 @@
import * as fs from 'fs'; import { existsSync, writeFileSync } from 'fs';
import * as yaml from 'js-yaml'; import { dump } from 'js-yaml';
import { join } from 'path'; import { join } from 'path';
/** /**
@@ -18,18 +18,18 @@ if (args[0] === undefined) {
const envFullPath = join(process.cwd(), args[0]); const envFullPath = join(process.cwd(), args[0]);
if (!fs.existsSync(envFullPath)) { if (!existsSync(envFullPath)) {
console.error(`Error:\n${envFullPath} does not exist\n`); console.error(`Error:\n${envFullPath} does not exist\n`);
process.exit(1); process.exit(1);
} }
try { try {
const env = require(envFullPath); const env = require(envFullPath).environment;
const config = yaml.dump(env); const config = dump(env);
if (args[1]) { if (args[1]) {
const ymlFullPath = join(process.cwd(), args[1]); const ymlFullPath = join(process.cwd(), args[1]);
fs.writeFileSync(ymlFullPath, config); writeFileSync(ymlFullPath, config);
} else { } else {
console.log(config); console.log(config);
} }

View File

@@ -1,4 +1,4 @@
import * as child from 'child_process'; import { spawn } from 'child_process';
import { AppConfig } from '../src/config/app-config.interface'; import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server'; 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 * 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( spawn(
`ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, `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 } { stdio: 'inherit', shell: true }
); );

10
scripts/sync-i18n-files.ts Executable file → Normal file
View File

@@ -1,4 +1,5 @@
import { projectRoot} from '../webpack/helpers'; import { projectRoot } from '../webpack/helpers';
const commander = require('commander'); const commander = require('commander');
const fs = require('fs'); const fs = require('fs');
const JSON5 = require('json5'); const JSON5 = require('json5');
@@ -119,7 +120,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) {
outputChunks.forEach(function (chunk) { outputChunks.forEach(function (chunk) {
progressBar.increment(); progressBar.increment();
chunk.split("\n").forEach(function (line) { chunk.split("\n").forEach(function (line) {
file.write(" " + line + "\n"); file.write((line === '' ? '' : ` ${line}`) + "\n");
}); });
}); });
file.write("\n}"); file.write("\n}");
@@ -192,7 +193,10 @@ function createNewChunkComparingSourceAndTarget(correspondingTargetChunk, source
const targetList = correspondingTargetChunk.split("\n"); const targetList = correspondingTargetChunk.split("\n");
const oldKeyValueInTargetComments = getSubStringWithRegex(correspondingTargetChunk, "\\s*\\/\\/\\s*\".*"); 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) { if (oldKeyValueInTargetComments != null) {
const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0]; const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0];

View File

@@ -1,9 +1,9 @@
import * as http from 'http'; import { request } from 'http';
import * as https from 'https'; import { request as https_request } from 'https';
import { AppConfig } from '../src/config/app-config.interface'; import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server'; import { buildAppConfig } from '../src/config/config.server';
const appConfig: AppConfig = buildAppConfig(); const appConfig: AppConfig = buildAppConfig();
/** /**
@@ -20,9 +20,15 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`);
// If SSL enabled, test via HTTPS, else via HTTP // If SSL enabled, test via HTTPS, else via HTTP
if (appConfig.rest.ssl) { if (appConfig.rest.ssl) {
const req = https.request(restUrl, (res) => { const req = https_request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); 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); checkJSONResponse(data);
}); });
}); });
@@ -33,9 +39,15 @@ if (appConfig.rest.ssl) {
req.end(); req.end();
} else { } else {
const req = http.request(restUrl, (res) => { const req = request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); 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); checkJSONResponse(data);
}); });
}); });

View File

@@ -15,16 +15,21 @@
* import for `ngExpressEngine`. * import for `ngExpressEngine`.
*/ */
import 'zone.js/dist/zone-node'; import 'zone.js/node';
import 'reflect-metadata'; import 'reflect-metadata';
import 'rxjs'; import 'rxjs';
import * as pem from 'pem'; /* eslint-disable import/no-namespace */
import * as https from 'https';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as express from 'express'; import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as compression from 'compression'; 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 { existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -37,15 +42,16 @@ import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment'; import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware'; 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 { UIServerConfig } from './src/config/ui-server-config.interface';
import { ServerAppModule } from './src/main.server'; import { ServerAppModule } from './src/main.server';
import { buildAppConfig } from './src/config/config.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'; import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
/* /*
* Set path for the browser application's dist folder * Set path for the browser application's dist folder
@@ -66,19 +72,29 @@ extendEnvironmentWithAppConfig(environment, appConfig);
// The Express app is exported so that it can be used by serverless Functions. // The Express app is exported so that it can be used by serverless Functions.
export function app() { export function app() {
const router = express.Router();
/* /*
* Create a new express application * Create a new express application
*/ */
const server = express(); 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: * If production mode is enabled in the environment file:
* - Enable Angular's production mode * - 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) { if (environment.production) {
enableProdMode(); 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,
}));
} }
/* /*
@@ -97,7 +113,7 @@ export function app() {
* Add parser for request bodies * Add parser for request bodies
* See [morgan](https://github.com/expressjs/body-parser) * 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) // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', (_, options, callback) => server.engine('html', (_, options, callback) =>
@@ -133,7 +149,11 @@ export function app() {
/** /**
* Proxy the sitemaps * 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 * Checks if the rateLimiter property is present
@@ -150,15 +170,28 @@ export function app() {
/* /*
* Serve static resources (images, i18n messages, …) * 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). * 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 // Register the ngApp callback function to handle incoming requests
server.get('*', ngApp); router.get('*', ngApp);
server.use(environment.ui.nameSpace, router);
return server; return server;
} }
@@ -180,6 +213,7 @@ function ngApp(req, res) {
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => { }, (err, data) => {
if (hasNoValue(err) && hasValue(data)) { if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR
res.send(data); res.send(data);
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { } 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 // When this error occurs we can't fall back to CSR because the response has already been
@@ -191,13 +225,25 @@ function ngApp(req, res) {
if (hasValue(err)) { if (hasValue(err)) {
console.warn('Error details : ', 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 { } else {
// If preboot is disabled, just serve the client // If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR'); 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
}]
});
} }
} }
@@ -223,7 +269,7 @@ function serverStarted() {
* @param keys SSL credentials * @param keys SSL credentials
*/ */
function createHttpsServer(keys) { function createHttpsServer(keys) {
https.createServer({ createServer({
key: keys.serviceKey, key: keys.serviceKey,
cert: keys.certificate cert: keys.certificate
}, app).listen(environment.ui.port, environment.ui.host, () => { }, app).listen(environment.ui.port, environment.ui.host, () => {
@@ -243,6 +289,8 @@ function run() {
} }
function start() { function start() {
logStartupMessage(environment);
/* /*
* If SSL is enabled * If SSL is enabled
* - Read credentials from configuration files * - Read credentials from configuration files
@@ -275,7 +323,7 @@ function start() {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation] process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // lgtm[js/disabling-certificate-validation]
pem.createCertificate({ createCertificate({
days: 1, days: 1,
selfSigned: true selfSigned: true
}, (error, keys) => { }, (error, keys) => {
@@ -287,6 +335,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__' // Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node '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. // The below code is to ensure that the server is run only when not requiring the bundle.

View File

@@ -10,8 +10,18 @@ import { MembersListComponent } from './group-registry/group-form/members-list/m
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module'; import { FormModule } from '../shared/form/form.module';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { AbstractControl } from '@angular/forms';
import { EPersonListComponent } from './group-registry/group-form/eperson-list/eperson-list.component'; import { EPersonListComponent } from './group-registry/group-form/eperson-list/eperson-list.component';
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
@@ -32,6 +42,12 @@ import { EPersonListComponent } from './group-registry/group-form/eperson-list/e
MembersListComponent, MembersListComponent,
EPersonListComponent, EPersonListComponent,
], ],
providers: [
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
]
}) })
/** /**
* This module handles all components related to the access control pages * This module handles all components related to the access control pages

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { type } from '../../shared/ngrx/type'; import { type } from '../../shared/ngrx/type';
@@ -16,7 +17,6 @@ export const EPeopleRegistryActionTypes = {
CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_EDIT_EPERSON'), CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_EDIT_EPERSON'),
}; };
/* tslint:disable:max-classes-per-file */
/** /**
* Used to edit an EPerson in the EPeople registry * Used to edit an EPerson in the EPeople registry
*/ */
@@ -37,7 +37,6 @@ export class EPeopleRegistryCancelEPersonAction implements Action {
type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON; type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON;
} }
/* tslint:enable:max-classes-per-file */
/** /**
* Export a type alias of all actions in this action group * Export a type alias of all actions in this action group

View File

@@ -45,7 +45,7 @@
</div> </div>
</form> </form>
<ds-loading *ngIf="searching$ | async"></ds-loading> <ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)" *ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config" [paginationOptions]="config"

View File

@@ -9,7 +9,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { FindListOptions } from '../../core/data/request.models';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
@@ -27,6 +26,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model';
describe('EPeopleRegistryComponent', () => { describe('EPeopleRegistryComponent', () => {
let component: EPeopleRegistryComponent; let component: EPeopleRegistryComponent;

View File

@@ -238,7 +238,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
if (restResponse.hasSucceeded) { if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name})); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name}));
this.reset();
} else { } else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
} }

View File

@@ -36,12 +36,12 @@
</button> </button>
</ds-form> </ds-form>
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading> <ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<div *ngIf="epersonService.getActiveEPerson() | async"> <div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5> <h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-loading> <ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0" *ngIf="(groups | async)?.payload?.totalElements > 0"

View File

@@ -8,7 +8,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { FindListOptions } from '../../../core/data/request.models';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model'; import { EPerson } from '../../../core/eperson/models/eperson.model';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
@@ -29,6 +28,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
@@ -177,7 +177,7 @@ describe('EPersonFormComponent', () => {
}); });
groupsDataService = jasmine.createSpyObj('groupsDataService', { groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '' getGroupRegistryRouterLink: ''
}); });

View File

@@ -265,7 +265,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null) { if (eperson != null) {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, { this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize
}); });
@@ -297,7 +297,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}), }),
switchMap(([eperson, findListOptions]) => { switchMap(([eperson, findListOptions]) => {
if (eperson != null) { if (eperson != null) {
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
} }
return observableOf(undefined); return observableOf(undefined);
}) })
@@ -554,7 +554,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
private updateGroups(options) { private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { 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);
})); }));
} }
} }

View File

@@ -53,7 +53,7 @@ describe('EPersonListComponent', () => {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, subgroupMembers: subgroupMembers,
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
@@ -152,7 +152,7 @@ describe('EPersonListComponent', () => {
fixture.debugElement.nativeElement.remove(); fixture.debugElement.nativeElement.remove();
})); }));
it('should create EpeopleListComponent', inject([EPersonListComponent], (comp: EPersonListComponent) => { it('should create EPersonListComponent', inject([EPersonListComponent], (comp: EPersonListComponent) => {
expect(comp).toBeDefined(); expect(comp).toBeDefined();
})); }));

View File

@@ -1,32 +1,32 @@
import { Component, OnDestroy, OnInit, Input } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
Subscription,
BehaviorSubject,
combineLatest as observableCombineLatest,
ObservedValueOf,
} from 'rxjs';
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'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { import {
getAllCompletedRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getRemoteDataPayload, getFirstCompletedRemoteData,
getFirstCompletedRemoteData getAllCompletedRemoteData,
getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { import { NotificationsService } from '../../../../shared/notifications/notifications.service';
BehaviorSubject,
Subscription,
combineLatest as observableCombineLatest,
Observable,
ObservedValueOf,
of as observableOf
} from 'rxjs';
import { PaginatedList, buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { switchMap, map, take, mergeMap } from 'rxjs/operators'; import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
/** /**
* Keys to keep track of specific subscriptions * Keys to keep track of specific subscriptions
@@ -152,7 +152,7 @@ export class EPersonListComponent implements OnInit, OnDestroy {
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.MembersDTO,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
elementsPerPage: currentPagination.pageSize elementsPerPage: currentPagination.pageSize
} }
@@ -167,7 +167,7 @@ export class EPersonListComponent implements OnInit, OnDestroy {
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => { this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -176,8 +176,8 @@ export class EPersonListComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})); })]);
return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
})); }));
})) }))
@@ -194,7 +194,7 @@ export class EPersonListComponent implements OnInit, OnDestroy {
return this.groupDataService.getActiveGroup().pipe(take(1), return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((group: Group) => { mergeMap((group: Group) => {
if (group != null) { if (group != null) {
return this.ePersonDataService.findAllByHref(group._links.epersons.href, { return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: 9999 elementsPerPage: 9999
}, false) }, false)
@@ -232,7 +232,6 @@ export class EPersonListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);
this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup); this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup);
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -297,7 +296,7 @@ export class EPersonListComponent implements OnInit, OnDestroy {
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => { this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -306,8 +305,8 @@ export class EPersonListComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})); })]);
return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
})); }));
})) }))
@@ -338,7 +337,6 @@ export class EPersonListComponent implements OnInit, OnDestroy {
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<any>) => { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<any>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
} }

View File

@@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store'; 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 { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator'; import { ValidateGroupExists } from './validators/group-exists.validator';
import { NoContent } from '../../../core/shared/NoContent.model';
describe('GroupFormComponent', () => { describe('GroupFormComponent', () => {
let component: GroupFormComponent; let component: GroupFormComponent;
@@ -87,6 +88,9 @@ describe('GroupFormComponent', () => {
patch(group: Group, operations: Operation[]) { patch(group: Group, operations: Operation[]) {
return null; return null;
}, },
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
cancelEditGroup(): void { cancelEditGroup(): void {
this.activeGroup = null; 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();
});
});
});
}); });

View File

@@ -46,6 +46,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator'; import { ValidateGroupExists } from './validators/group-exists.validator';
import { environment } from '../../../../environments/environment';
@Component({ @Component({
selector: 'ds-group-form', selector: 'ds-group-form',
@@ -194,6 +195,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
label: groupDescription, label: groupDescription,
name: 'groupDescription', name: 'groupDescription',
required: false, required: false,
spellCheck: environment.form.spellCheck,
}); });
this.formModel = [ this.formModel = [
this.groupName, this.groupName,
@@ -426,7 +428,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
.subscribe((rd: RemoteData<NoContent>) => { .subscribe((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name }));
this.reset(); this.onCancel();
} else { } else {
this.notificationsService.error( this.notificationsService.error(
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }), this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }),
@@ -439,16 +441,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 * Cancel the current edit when component is destroyed & unsub all subscriptions
*/ */

View File

@@ -53,7 +53,7 @@ describe('MembersListComponent', () => {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, subgroupMembers: subgroupMembers,
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {

View File

@@ -10,7 +10,7 @@ import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
ObservedValueOf, ObservedValueOf,
} from 'rxjs'; } 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 {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
@@ -131,7 +131,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.MembersDTO,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
elementsPerPage: currentPagination.pageSize elementsPerPage: currentPagination.pageSize
} }
@@ -146,7 +146,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => { this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -155,8 +155,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})); })]);
return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
})); }));
})) }))
@@ -173,10 +173,10 @@ export class MembersListComponent implements OnInit, OnDestroy {
return this.groupDataService.getActiveGroup().pipe(take(1), return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((group: Group) => { mergeMap((group: Group) => {
if (group != null) { if (group != null) {
return this.ePersonDataService.findAllByHref(group._links.epersons.href, { return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: 9999 elementsPerPage: 9999
}, false) })
.pipe( .pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
@@ -211,7 +211,6 @@ export class MembersListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);
this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup); this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup);
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -276,7 +275,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => { this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -285,8 +284,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})); })]);
return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
})); }));
})) }))
@@ -317,7 +316,6 @@ export class MembersListComponent implements OnInit, OnDestroy {
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<any>) => { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<any>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
} }

View File

@@ -65,7 +65,7 @@ describe('SubgroupsListComponent', () => {
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.activeGroup;
}, },
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
return this.subgroups$.pipe( return this.subgroups$.pipe(
map((currentGroups: Group[]) => { map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups)); return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));

View File

@@ -115,7 +115,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
this.subs.set( this.subs.set(
SubKey.Members, SubKey.Members,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( 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, currentPage: config.currentPage,
elementsPerPage: config.pageSize elementsPerPage: config.pageSize
}, },
@@ -139,7 +139,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup.uuid === possibleSubgroup.uuid) { if (activeGroup.uuid === possibleSubgroup.uuid) {
return observableOf(false); return observableOf(false);
} else { } else {
return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, { return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: 9999 elementsPerPage: 9999
}) })

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Group } from '../../core/eperson/models/group.model'; import { Group } from '../../core/eperson/models/group.model';
import { type } from '../../shared/ngrx/type'; import { type } from '../../shared/ngrx/type';
@@ -16,7 +17,6 @@ export const GroupRegistryActionTypes = {
CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'), CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'),
}; };
/* tslint:disable:max-classes-per-file */
/** /**
* Used to edit a Group in the Group registry * Used to edit a Group in the Group registry
*/ */
@@ -37,7 +37,6 @@ export class GroupRegistryCancelGroupAction implements Action {
type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP; type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP;
} }
/* tslint:enable:max-classes-per-file */
/** /**
* Export a type alias of all actions in this action group * Export a type alias of all actions in this action group

View File

@@ -33,7 +33,7 @@
</div> </div>
</form> </form>
<ds-loading *ngIf="loading$ | async"></ds-loading> <ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)" *ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)"
[paginationOptions]="config" [paginationOptions]="config"
@@ -79,7 +79,7 @@
</button> </button>
</ng-container> </ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete" <button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm" (click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}"> title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>

View File

@@ -31,6 +31,7 @@ import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { NoContent } from '../../core/shared/NoContent.model';
describe('GroupRegistryComponent', () => { describe('GroupRegistryComponent', () => {
let component: GroupsRegistryComponent; let component: GroupsRegistryComponent;
@@ -68,7 +69,7 @@ describe('GroupRegistryComponent', () => {
mockGroups = [GroupMock, GroupMock2]; mockGroups = [GroupMock, GroupMock2];
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
switch (href) { switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
@@ -96,7 +97,7 @@ describe('GroupRegistryComponent', () => {
}; };
groupsDataServiceStub = { groupsDataServiceStub = {
allGroups: mockGroups, allGroups: mockGroups,
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
switch (href) { switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
@@ -145,7 +146,10 @@ describe('GroupRegistryComponent', () => {
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1
}), [result])); }), [result]));
} },
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return createSuccessfulRemoteDataObject$({});
},
}; };
dsoDataServiceStub = { dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> { findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
@@ -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);
});
});
}); });

View File

@@ -5,11 +5,12 @@ import { TranslateService } from '@ngx-translate/core';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
EMPTY,
Observable, Observable,
of as observableOf, of as observableOf,
Subscription Subscription
} from 'rxjs'; } from 'rxjs';
import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; import { catchError, defaultIfEmpty, map, switchMap, tap } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; 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( return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
switchMap((isSiteAdmin: boolean) => { 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)) { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) {
return observableCombineLatest([ return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self), 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); return buildPaginatedList(groups.pageInfo, dtos);
})); }));
}) })
@@ -199,7 +202,6 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id]; this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id];
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name }));
this.reset();
} else { } else {
this.notificationsService.error( this.notificationsService.error(
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }), this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }),
@@ -209,23 +211,12 @@ 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) * Get the members (epersons embedded value of a group)
* @param group * @param group
*/ */
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> { getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
return this.ePersonDataService.findAllByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData());
} }
/** /**
@@ -233,7 +224,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
* @param group * @param group
*/ */
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> { getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
return this.groupService.findAllByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData());
} }
/** /**

View File

@@ -0,0 +1,35 @@
<div class="container">
<h2 id="header">{{'admin.batch-import.page.header' | translate}}</h2>
<p>{{'admin.batch-import.page.help' | translate}}</p>
<p *ngIf="dso">
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;
<a href="javascript:void(0)" (click)="removeDspaceObject()">{{'admin.batch-import.page.remove' | translate}}</a>
</p>
<p>
<button class="btn btn-primary" (click)="this.selectCollection();">{{'admin.metadata-import.page.button.select-collection' | translate}}</button>
</p>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly">
{{'admin.metadata-import.page.validateOnly' | translate}}
</label>
</div>
<small id="validateOnlyHelpBlock" class="form-text text-muted">
{{'admin.batch-import.page.validateOnly.hint' | translate}}
</small>
</div>
<ds-file-dropzone-no-uploader
(onFileAdded)="setFile($event)"
[dropMessageLabel]="'admin.batch-import.page.dropMsg'"
[dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader>
<div class="space-children-mr">
<button class="btn btn-secondary" id="backButton"
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
<button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div>
</div>

View File

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

View File

@@ -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<Process>) => {
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;
}
}

View File

@@ -1,6 +1,17 @@
<div class="container"> <div class="container">
<h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2> <h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2>
<p>{{'admin.metadata-import.page.help' | translate}}</p> <p>{{'admin.metadata-import.page.help' | translate}}</p>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly">
{{'admin.metadata-import.page.validateOnly' | translate}}
</label>
</div>
<small id="validateOnlyHelpBlock" class="form-text text-muted">
{{'admin.metadata-import.page.validateOnly.hint' | translate}}
</small>
</div>
<ds-file-dropzone-no-uploader <ds-file-dropzone-no-uploader
(onFileAdded)="setFile($event)" (onFileAdded)="setFile($event)"
@@ -8,8 +19,10 @@
[dropMessageLabelReplacement]="'admin.metadata-import.page.dropMsgReplace'"> [dropMessageLabelReplacement]="'admin.metadata-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader> </ds-file-dropzone-no-uploader>
<button class="btn btn-secondary" id="backButton" <div class="space-children-mr">
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button> <button class="btn btn-secondary" id="backButton"
<button class="btn btn-primary" id="proceedButton" (click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button> <button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div>
</div> </div>

View File

@@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => {
comp.setFile(fileMock); comp.setFile(fileMock);
}); });
describe('if proceed button is pressed', () => { describe('if proceed button is pressed without validate only', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
comp.validateOnly = false;
const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement;
proceed.click(); proceed.click();
fixture.detectChanges(); 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', () => { describe('if proceed is pressed; but script invoke fails', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
jasmine.getEnv().allowRespy(true); jasmine.getEnv().allowRespy(true);

View File

@@ -1,12 +1,8 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; 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 { 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 { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -30,6 +26,11 @@ export class MetadataImportPageComponent {
*/ */
fileObject: File; fileObject: File;
/**
* The validate only flag
*/
validateOnly = true;
public constructor(private location: Location, public constructor(private location: Location,
protected translate: TranslateService, protected translate: TranslateService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
@@ -62,6 +63,9 @@ export class MetadataImportPageComponent {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), 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( this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type'; import { type } from '../../../shared/ngrx/type';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
@@ -17,7 +18,6 @@ export const BitstreamFormatsRegistryActionTypes = {
DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT') DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT')
}; };
/* tslint:disable:max-classes-per-file */
/** /**
* Used to select a single bitstream format in the bitstream format registry * Used to select a single bitstream format in the bitstream format registry
*/ */
@@ -51,7 +51,6 @@ export class BitstreamFormatsRegistryDeselectAllAction implements Action {
type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT; type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT;
} }
/* tslint:enable:max-classes-per-file */
/** /**
* Export a type alias of all actions in this action group * Export a type alias of all actions in this action group

View File

@@ -13,32 +13,34 @@
[paginationOptions]="pageConfig" [paginationOptions]="pageConfig"
[pageInfoState]="(bitstreamFormats | async)?.payload" [pageInfoState]="(bitstreamFormats | async)?.payload"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements" [collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
[hideGear]="true" [hideGear]="false"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
<div class="table-responsive"> <div class="table-responsive">
<table id="formats" class="table table-striped table-hover"> <table id="formats" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col"></th> <th scope="col"></th>
<th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
</tr> <th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page"> <tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td> <td>
<label> <label>
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async" [checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)" (change)="selectBitStreamFormat(bitstreamFormat, $event)"
> >
</label> </label>
</td> </td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td>
</tr> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -20,12 +20,10 @@ import { TestScheduler } from 'rxjs/testing';
import { import {
createNoContentRemoteDataObject$, createNoContentRemoteDataObject$,
createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject$
} from '../../../shared/remote-data.utils'; } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../../core/data/request.models';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
@@ -85,10 +83,6 @@ describe('BitstreamFormatsComponent', () => {
]; ];
const mockFormatsRD = createSuccessfulRemoteDataObject(createPaginatedList(mockFormatsList)); 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 = () => { const initAsync = () => {
notificationsServiceStub = new NotificationsServiceStub(); notificationsServiceStub = new NotificationsServiceStub();
@@ -135,16 +129,19 @@ describe('BitstreamFormatsComponent', () => {
}); });
it('should contain the correct formats', () => { 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'); 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'); 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'); 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'); expect(adobeName.textContent).toBe('Adobe PDF');
}); });
}); });
@@ -246,7 +243,7 @@ describe('BitstreamFormatsComponent', () => {
)); ));
beforeEach(initBeforeEach); beforeEach(initBeforeEach);
it('should clear bitstream formats ', () => { it('should clear bitstream formats and show a success notification', () => {
comp.deleteFormats(); comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();
@@ -275,7 +272,7 @@ describe('BitstreamFormatsComponent', () => {
selectBitstreamFormat: {}, selectBitstreamFormat: {},
deselectBitstreamFormat: {}, deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {}, deselectAllBitstreamFormats: {},
delete: observableOf(false), delete: createFailedRemoteDataObject$(),
clearBitStreamFormatRequests: observableOf('cleared') clearBitStreamFormatRequests: observableOf('cleared')
}); });
@@ -295,7 +292,7 @@ describe('BitstreamFormatsComponent', () => {
)); ));
beforeEach(initBeforeEach); beforeEach(initBeforeEach);
it('should clear bitstream formats ', () => { it('should clear bitstream formats and show an error notification', () => {
comp.deleteFormats(); comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();

View File

@@ -1,18 +1,18 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; 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 { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
import { FindListOptions } from '../../../core/data/request.models'; import { map, mergeMap, switchMap, take, toArray } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
/** /**
* This component renders a list of bitstream formats * This component renders a list of bitstream formats
@@ -28,21 +28,14 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
*/ */
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>; bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
/**
* 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 * The current pagination configuration for the page
* Currently simply renders all bitstream formats * Currently simply renders all bitstream formats
*/ */
pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'rbp', id: 'rbp',
pageSize: 20 pageSize: 20,
pageSizeOptions: [20, 40, 60, 80, 100]
}); });
constructor(private notificationsService: NotificationsService, constructor(private notificationsService: NotificationsService,
@@ -50,7 +43,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
private translateService: TranslateService, private translateService: TranslateService,
private bitstreamFormatService: BitstreamFormatDataService, private bitstreamFormatService: BitstreamFormatDataService,
private paginationService: PaginationService, 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 * Deletes the currently selected formats from the registry and updates the presented list
*/ */
deleteFormats() { deleteFormats() {
this.bitstreamFormatService.clearBitStreamFormatRequests().subscribe(); this.bitstreamFormatService.clearBitStreamFormatRequests();
this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(take(1)).subscribe( this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(
(formats) => { take(1),
const tasks$ = []; // emit all formats in the array one at a time
for (const format of formats) { mergeMap((formats: BitstreamFormat[]) => formats),
if (hasValue(format.id)) { // delete each format
tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RemoteData<NoContent>) => response.hasSucceeded))); mergeMap((format: BitstreamFormat) => this.bitstreamFormatService.delete(format.id).pipe(
} // wait for each response to come back
} getFirstCompletedRemoteData(),
zip(...tasks$).subscribe((results: boolean[]) => { // return a boolean to indicate whether a response succeeded
const successResponses = results.filter((result: boolean) => result); map((response: RemoteData<NoContent>) => response.hasSucceeded),
const failedResponses = results.filter((result: boolean) => !result); )),
if (successResponses.length > 0) { // wait for all responses to come in and return them as a single array
this.showNotification(true, successResponses.length); toArray()
} ).subscribe((results: boolean[]) => {
if (failedResponses.length > 0) { // Count the number of succeeded and failed deletions
this.showNotification(false, failedResponses.length); const successResponses = results.filter((result: boolean) => result);
} const failedResponses = results.filter((result: boolean) => !result);
this.deselectAll(); // Show a notification indicating the number of succeeded and failed deletions
if (successResponses.length > 0) {
this.paginationService.resetPage(this.pageConfig.id); 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 { 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) => { switchMap((findListOptions: FindListOptions) => {
return this.bitstreamFormatService.findAll(findListOptions); return this.bitstreamFormatService.findAll(findListOptions);
}) })

View File

@@ -15,6 +15,7 @@ import { Router } from '@angular/router';
import { hasValue, isEmpty } from '../../../../shared/empty.util'; import { hasValue, isEmpty } from '../../../../shared/empty.util';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths'; import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths';
import { environment } from '../../../../../environments/environment';
/** /**
* The component responsible for rendering the form to create/edit a bitstream format * The component responsible for rendering the form to create/edit a bitstream format
@@ -90,6 +91,7 @@ export class FormatFormComponent implements OnInit {
name: 'description', name: 'description',
label: 'admin.registries.bitstream-formats.edit.description.label', label: 'admin.registries.bitstream-formats.edit.description.label',
hint: 'admin.registries.bitstream-formats.edit.description.hint', hint: 'admin.registries.bitstream-formats.edit.description.hint',
spellCheck: environment.form.spellCheck,
}), }),
new DynamicSelectModel({ new DynamicSelectModel({

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type'; import { type } from '../../../shared/ngrx/type';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
@@ -26,7 +27,6 @@ export const MetadataRegistryActionTypes = {
DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD') DESELECT_ALL_FIELD: type('dspace/metadata-registry/DESELECT_ALL_FIELD')
}; };
/* tslint:disable:max-classes-per-file */
/** /**
* Used to edit a metadata schema in the metadata registry * Used to edit a metadata schema in the metadata registry
*/ */
@@ -133,7 +133,6 @@ export class MetadataRegistryDeselectAllFieldAction implements Action {
type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD; type = MetadataRegistryActionTypes.DESELECT_ALL_FIELD;
} }
/* tslint:enable:max-classes-per-file */
/** /**
* Export a type alias of all actions in this action group * Export a type alias of all actions in this action group

View File

@@ -19,9 +19,6 @@ import { RestResponse } from '../../../core/cache/response.models';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../../core/data/request.models';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
describe('MetadataRegistryComponent', () => { describe('MetadataRegistryComponent', () => {
@@ -52,7 +49,7 @@ describe('MetadataRegistryComponent', () => {
} }
]; ];
const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList));
/* tslint:disable:no-empty */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = { const registryServiceStub = {
getMetadataSchemas: () => mockSchemas, getMetadataSchemas: () => mockSchemas,
getActiveMetadataSchema: () => observableOf(undefined), getActiveMetadataSchema: () => observableOf(undefined),
@@ -66,7 +63,7 @@ describe('MetadataRegistryComponent', () => {
}, },
clearMetadataSchemaRequests: () => observableOf(undefined) clearMetadataSchemaRequests: () => observableOf(undefined)
}; };
/* tslint:enable:no-empty */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();

View File

@@ -128,7 +128,6 @@ export class MetadataRegistryComponent {
* Delete all the selected metadata schemas * Delete all the selected metadata schemas
*/ */
deleteSchemas() { deleteSchemas() {
this.registryService.clearMetadataSchemaRequests().subscribe();
this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe(
(schemas) => { (schemas) => {
const tasks$ = []; const tasks$ = [];
@@ -148,7 +147,6 @@ export class MetadataRegistryComponent {
} }
this.registryService.deselectAllMetadataSchema(); this.registryService.deselectAllMetadataSchema();
this.registryService.cancelEditMetadataSchema(); this.registryService.cancelEditMetadataSchema();
this.forceUpdateSchemas();
}); });
} }
); );

View File

@@ -17,7 +17,7 @@ describe('MetadataSchemaFormComponent', () => {
let fixture: ComponentFixture<MetadataSchemaFormComponent>; let fixture: ComponentFixture<MetadataSchemaFormComponent>;
let registryService: RegistryService; let registryService: RegistryService;
/* tslint:disable:no-empty */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = { const registryServiceStub = {
getActiveMetadataSchema: () => observableOf(undefined), getActiveMetadataSchema: () => observableOf(undefined),
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
@@ -33,7 +33,7 @@ describe('MetadataSchemaFormComponent', () => {
}; };
} }
}; };
/* tslint:enable:no-empty */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@@ -24,7 +24,7 @@ describe('MetadataFieldFormComponent', () => {
prefix: 'fake' prefix: 'fake'
}); });
/* tslint:disable:no-empty */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = { const registryServiceStub = {
getActiveMetadataField: () => observableOf(undefined), getActiveMetadataField: () => observableOf(undefined),
createMetadataField: (field: MetadataField) => observableOf(field), createMetadataField: (field: MetadataField) => observableOf(field),
@@ -43,7 +43,7 @@ describe('MetadataFieldFormComponent', () => {
}; };
} }
}; };
/* tslint:enable:no-empty */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@@ -25,6 +25,7 @@
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
</tr> </tr>
@@ -39,6 +40,7 @@
(change)="selectMetadataField(field, $event)"> (change)="selectMetadataField(field, $event)">
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td> <td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr> </tr>

View File

@@ -23,9 +23,6 @@ import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { VarDirective } from '../../../shared/utils/var.directive'; import { VarDirective } from '../../../shared/utils/var.directive';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../../core/data/request.models';
import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
@@ -106,7 +103,7 @@ describe('MetadataSchemaComponent', () => {
} }
]; ];
const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList));
/* tslint:disable:no-empty */ /* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = { const registryServiceStub = {
getMetadataSchemas: () => mockSchemas, getMetadataSchemas: () => mockSchemas,
getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))), getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))),
@@ -122,7 +119,7 @@ describe('MetadataSchemaComponent', () => {
}, },
clearMetadataFieldRequests: () => observableOf(undefined) clearMetadataFieldRequests: () => observableOf(undefined)
}; };
/* tslint:enable:no-empty */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
const schemaNameParam = 'mock'; const schemaNameParam = 'mock';
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: observableOf({ params: observableOf({
@@ -169,10 +166,10 @@ describe('MetadataSchemaComponent', () => {
}); });
it('should contain the correct fields', () => { 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'); 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'); expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
}); });

View File

@@ -174,15 +174,12 @@ export class MetadataSchemaComponent implements OnInit {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed); const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) { if (successResponses.length > 0) {
this.showNotification(true, successResponses.length); this.showNotification(true, successResponses.length);
this.registryService.clearMetadataFieldRequests();
} }
if (failedResponses.length > 0) { if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length); this.showNotification(false, failedResponses.length);
} }
this.registryService.deselectAllMetadataField(); this.registryService.deselectAllMetadataField();
this.registryService.cancelEditMetadataField(); this.registryService.cancelEditMetadataField();
this.forceUpdateFields();
}); });
} }
); );

View File

@@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -40,6 +41,12 @@ import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
component: MetadataImportPageComponent, component: MetadataImportPageComponent,
data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } 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: [ providers: [

View File

@@ -14,6 +14,14 @@ import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths';
import { LinkService } from '../../../../../core/cache/builders/link.service'; 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', () => { describe('CollectionAdminSearchResultGridElementComponent', () => {
let component: CollectionAdminSearchResultGridElementComponent; let component: CollectionAdminSearchResultGridElementComponent;
@@ -45,7 +53,11 @@ describe('CollectionAdminSearchResultGridElementComponent', () => {
providers: [ providers: [
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: {} }, { 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(); .compileComponents();

View File

@@ -16,6 +16,14 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s
import { Community } from '../../../../../core/shared/community.model'; import { Community } from '../../../../../core/shared/community.model';
import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths';
import { LinkService } from '../../../../../core/cache/builders/link.service'; 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', () => { describe('CommunityAdminSearchResultGridElementComponent', () => {
let component: CommunityAdminSearchResultGridElementComponent; let component: CommunityAdminSearchResultGridElementComponent;
@@ -47,7 +55,11 @@ describe('CommunityAdminSearchResultGridElementComponent', () => {
providers: [ providers: [
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: {} }, { 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] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -18,6 +18,14 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../../../../shared/theme-support/theme.service'; 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', () => { describe('ItemAdminSearchResultGridElementComponent', () => {
let component: ItemAdminSearchResultGridElementComponent; let component: ItemAdminSearchResultGridElementComponent;
@@ -31,6 +39,12 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
} }
}; };
const mockAccessStatusDataService = {
findAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
return createSuccessfulRemoteDataObject$(new AccessStatusObject());
}
};
const mockThemeService = getMockThemeService(); const mockThemeService = getMockThemeService();
function init() { function init() {
@@ -55,6 +69,10 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: ThemeService, useValue: mockThemeService }, { provide: ThemeService, useValue: mockThemeService },
{ provide: AccessStatusDataService, useValue: mockAccessStatusDataService },
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: FileService, useClass: FileServiceStub },
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing';
import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
describe('CollectionAdminSearchResultListElementComponent', () => { describe('CollectionAdminSearchResultListElementComponent', () => {
let component: CollectionAdminSearchResultListElementComponent; let component: CollectionAdminSearchResultListElementComponent;
@@ -36,7 +38,8 @@ describe('CollectionAdminSearchResultListElementComponent', () => {
], ],
declarations: [CollectionAdminSearchResultListElementComponent], declarations: [CollectionAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }, providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }], { provide: DSONameService, useClass: DSONameServiceMock },
{ provide: APP_CONFIG, useValue: environment }],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })
.compileComponents(); .compileComponents();

View File

@@ -13,6 +13,8 @@ import { Community } from '../../../../../core/shared/community.model';
import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
describe('CommunityAdminSearchResultListElementComponent', () => { describe('CommunityAdminSearchResultListElementComponent', () => {
let component: CommunityAdminSearchResultListElementComponent; let component: CommunityAdminSearchResultListElementComponent;
@@ -36,7 +38,8 @@ describe('CommunityAdminSearchResultListElementComponent', () => {
], ],
declarations: [CommunityAdminSearchResultListElementComponent], declarations: [CommunityAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }, providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }], { provide: DSONameService, useClass: DSONameServiceMock },
{ provide: APP_CONFIG, useValue: environment }],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })
.compileComponents(); .compileComponents();

View File

@@ -10,6 +10,8 @@ import { ItemAdminSearchResultListElementComponent } from './item-admin-search-r
import { Item } from '../../../../../core/shared/item.model'; import { Item } from '../../../../../core/shared/item.model';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
describe('ItemAdminSearchResultListElementComponent', () => { describe('ItemAdminSearchResultListElementComponent', () => {
let component: ItemAdminSearchResultListElementComponent; let component: ItemAdminSearchResultListElementComponent;
@@ -33,7 +35,8 @@ describe('ItemAdminSearchResultListElementComponent', () => {
], ],
declarations: [ItemAdminSearchResultListElementComponent], declarations: [ItemAdminSearchResultListElementComponent],
providers: [{ provide: TruncatableService, useValue: {} }, providers: [{ provide: TruncatableService, useValue: {} },
{ provide: DSONameService, useClass: DSONameServiceMock }], { provide: DSONameService, useClass: DSONameServiceMock },
{ provide: APP_CONFIG, useValue: environment }],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })
.compileComponents(); .compileComponents();

View File

@@ -1,28 +1,30 @@
<a [ngClass]="{'btn-sm': small}" class="btn btn-secondary my-1 move-link" [routerLink]="[getMoveRoute()]" [title]="'admin.search.item.move' | translate"> <div class="space-children-mr my-1">
<i class="fa fa-arrow-circle-right"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.move" | translate}}</span> <a [ngClass]="{'btn-sm': small}" class="btn btn-secondary move-link" [routerLink]="[getMoveRoute()]" [title]="'admin.search.item.move' | translate">
</a> <i class="fa fa-arrow-circle-right"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.move" | translate}}</span>
</a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isDiscoverable" class="btn btn-secondary my-1 private-link" [routerLink]="[getPrivateRoute()]" [title]="'admin.search.item.make-private' | translate"> <a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isDiscoverable" class="btn btn-secondary private-link" [routerLink]="[getPrivateRoute()]" [title]="'admin.search.item.make-private' | translate">
<i class="fa fa-eye-slash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-private" | translate}}</span> <i class="fa fa-eye-slash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-private" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isDiscoverable" class="btn btn-secondary my-1 public-link" [routerLink]="[getPublicRoute()]" [title]="'admin.search.item.make-public' | translate"> <a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isDiscoverable" class="btn btn-secondary public-link" [routerLink]="[getPublicRoute()]" [title]="'admin.search.item.make-public' | translate">
<i class="fa fa-eye"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-public" | translate}}</span> <i class="fa fa-eye"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.make-public" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-secondary my-1 edit-link" [routerLink]="[getEditRoute()]" [title]="'admin.search.item.edit' | translate"> <a [ngClass]="{'btn-sm': small}" class="btn btn-secondary edit-link" [routerLink]="[getEditRoute()]" [title]="'admin.search.item.edit' | translate">
<i class="fa fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.edit" | translate}}</span> <i class="fa fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.edit" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isWithdrawn" class="btn btn-warning t my-1 withdraw-link" [routerLink]="[getWithdrawRoute()]" [title]="'admin.search.item.withdraw' | translate"> <a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isWithdrawn" class="btn btn-warning t withdraw-link" [routerLink]="[getWithdrawRoute()]" [title]="'admin.search.item.withdraw' | translate">
<i class="fa fa-ban"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.withdraw" | translate}}</span> <i class="fa fa-ban"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.withdraw" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isWithdrawn" class="btn btn-warning my-1 reinstate-link" [routerLink]="[getReinstateRoute()]" [title]="'admin.search.item.reinstate' | translate"> <a [ngClass]="{'btn-sm': small}" *ngIf="item && item.isWithdrawn" class="btn btn-warning reinstate-link" [routerLink]="[getReinstateRoute()]" [title]="'admin.search.item.reinstate' | translate">
<i class="fa fa-undo"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.reinstate" | translate}}</span> <i class="fa fa-undo"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.reinstate" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-danger my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.search.item.delete' | translate"> <a [ngClass]="{'btn-sm': small}" class="btn btn-danger delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.search.item.delete' | translate">
<i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.delete" | translate}}</span> <i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.delete" | translate}}</span>
</a> </a>
</div>

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub'; 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 { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';

View File

@@ -1,10 +1,10 @@
import { Component, Inject, Injector, OnInit } from '@angular/core'; import { Component, Inject, Injector, OnInit } from '@angular/core';
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component'; import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
import { MenuID } from '../../../shared/menu/initial-menus-state';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu.reducer'; import { MenuSection } from '../../../shared/menu/menu-section.model';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { isNotEmpty } from '../../../shared/empty.util'; import { isNotEmpty } from '../../../shared/empty.util';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -12,7 +12,7 @@ import { Router } from '@angular/router';
* Represents a non-expandable section in the admin sidebar * Represents a non-expandable section in the admin sidebar
*/ */
@Component({ @Component({
/* tslint:disable:component-selector */ /* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-admin-sidebar-section]', selector: 'li[ds-admin-sidebar-section]',
templateUrl: './admin-sidebar-section.component.html', templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'], styleUrls: ['./admin-sidebar-section.component.scss'],

View File

@@ -1,4 +1,4 @@
<nav @slideHorizontal class="navbar navbar-dark p-0" <nav class="navbar navbar-dark p-0"
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}" [ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
[@slideSidebar]="{ [@slideSidebar]="{
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'), value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),

View File

@@ -6,7 +6,7 @@ import { ScriptDataService } from '../../core/data/processes/script-data.service
import { AdminSidebarComponent } from './admin-sidebar.component'; import { AdminSidebarComponent } from './admin-sidebar.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; 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 { CSSVariableServiceStub } from '../../shared/testing/css-variable-service.stub';
import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { AuthServiceStub } from '../../shared/testing/auth-service.stub';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
@@ -16,10 +16,11 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import createSpy = jasmine.createSpy; import createSpy = jasmine.createSpy;
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { ThemeService } from '../../shared/theme-support/theme.service';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
describe('AdminSidebarComponent', () => { describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent; let comp: AdminSidebarComponent;
@@ -60,6 +61,7 @@ describe('AdminSidebarComponent', () => {
declarations: [AdminSidebarComponent], declarations: [AdminSidebarComponent],
providers: [ providers: [
Injector, Injector,
{ provide: ThemeService, useValue: getMockThemeService() },
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub }, { provide: AuthService, useClass: AuthServiceStub },
@@ -182,150 +184,4 @@ describe('AdminSidebarComponent', () => {
expect(menuService.collapseMenuPreview).toHaveBeenCalled(); expect(menuService.collapseMenuPreview).toHaveBeenCalled();
})); }));
}); });
describe('menu', () => {
beforeEach(() => {
spyOn(menuService, 'addSection');
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => {
return observableOf(false);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should not show site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: false,
}));
});
it('should not show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
});
it('should not show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
});
it('should not show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: false,
}));
});
});
describe('for site admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.AdministratorOf);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should contain site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'admin_search', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'registries', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'curation_tasks', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'workflow', visible: true,
}));
});
});
describe('for community admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCommunityAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_community', visible: true,
}));
});
});
describe('for collection admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.IsCollectionAdmin);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'edit_collection', visible: true,
}));
});
});
describe('for group admin', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanManageGroups);
});
});
beforeEach(() => {
comp.createMenu();
});
it('should show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
id: 'access_control', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({
parentID: 'access_control', visible: true,
}));
});
});
});
}); });

View File

@@ -1,27 +1,15 @@
import { Component, HostListener, Injector, OnInit } from '@angular/core'; import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { combineLatest, combineLatest as observableCombineLatest, Observable, BehaviorSubject } from 'rxjs'; import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators';
import { debounceTime, first, map, take, distinctUntilChanged, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { ScriptDataService } from '../../core/data/processes/script-data.service'; import { slideSidebar } from '../../shared/animations/slide';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
import { ExportMetadataSelectorComponent } from '../../shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model';
import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model';
import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model';
import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../../shared/sass-helper/css-variable.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { MenuID } from '../../shared/menu/menu-id.model';
import { Router, ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ThemeService } from '../../shared/theme-support/theme.service';
/** /**
* Component representing the admin sidebar * Component representing the admin sidebar
@@ -30,7 +18,7 @@ import { Router, ActivatedRoute } from '@angular/router';
selector: 'ds-admin-sidebar', selector: 'ds-admin-sidebar',
templateUrl: './admin-sidebar.component.html', templateUrl: './admin-sidebar.component.html',
styleUrls: ['./admin-sidebar.component.scss'], styleUrls: ['./admin-sidebar.component.scss'],
animations: [slideHorizontal, slideSidebar] animations: [slideSidebar]
}) })
export class AdminSidebarComponent extends MenuComponent implements OnInit { export class AdminSidebarComponent extends MenuComponent implements OnInit {
/** /**
@@ -63,16 +51,16 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
inFocus$: BehaviorSubject<boolean>; inFocus$: BehaviorSubject<boolean>;
constructor(protected menuService: MenuService, constructor(
protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
private variableService: CSSVariableService, private variableService: CSSVariableService,
private authService: AuthService, private authService: AuthService,
private modalService: NgbModal,
public authorizationService: AuthorizationDataService, public authorizationService: AuthorizationDataService,
private scriptDataService: ScriptDataService, public route: ActivatedRoute,
public route: ActivatedRoute protected themeService: ThemeService
) { ) {
super(menuService, injector, authorizationService, route); super(menuService, injector, authorizationService, route, themeService);
this.inFocus$ = new BehaviorSubject(false); this.inFocus$ = new BehaviorSubject(false);
} }
@@ -80,9 +68,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* Set and calculate all initial values of the instance variables * Set and calculate all initial values of the instance variables
*/ */
ngOnInit(): void { ngOnInit(): void {
this.createMenu();
super.ngOnInit(); super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth'); this.sidebarWidth = this.variableService.getVariable('--ds-sidebar-items-width');
this.authService.isAuthenticated() this.authService.isAuthenticated()
.subscribe((loggedIn: boolean) => { .subscribe((loggedIn: boolean) => {
if (loggedIn) { if (loggedIn) {
@@ -115,501 +102,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
}); });
} }
/**
* Initialize all menu sections and items for this menu
*/
createMenu() {
this.createMainMenuSections();
this.createSiteAdministratorMenuSections();
this.createExportMenuSections();
this.createImportMenuSections();
this.createAccessControlMenuSections();
}
/**
* Initialize the main menu sections.
* edit_community / edit_collection is only included if the current user is a Community or Collection admin
*/
createMainMenuSections() {
combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf)
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => {
const menuList = [
/* News */
{
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
},
{
id: 'new_community',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
parentID: 'new',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
parentID: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_process',
parentID: 'new',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.new_process',
link: '/processes/new'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Edit */
{
id: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
},
{
id: 'edit_community',
parentID: 'edit',
active: false,
visible: isCommunityAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
parentID: 'edit',
active: false,
visible: isCollectionAdmin,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
parentID: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Statistics */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'statistics_task',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.statistics_task',
// link: ''
// } as LinkMenuItemModel,
// icon: 'chart-bar',
// index: 8
// },
/* Control Panel */
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'control_panel',
// active: false,
// visible: isSiteAdmin,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.control_panel',
// link: ''
// } as LinkMenuItemModel,
// icon: 'cogs',
// index: 9
// },
/* Processes */
{
id: 'processes',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.processes',
link: '/processes'
} as LinkMenuItemModel,
icon: 'terminal',
index: 10
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* Create menu sections dependent on whether or not the current user is a site administrator and on whether or not
* the export scripts exist and the current user is allowed to execute them
*/
createExportMenuSections() {
const menuList = [
/* Export */
{
id: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.export'
} as TextMenuItemModel,
icon: 'file-export',
index: 3,
shouldPersistOnRouteChange: true
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_community',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_community',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_collection',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_collection',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'export_item',
// parentID: 'export',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.export_item',
// link: ''
// } as LinkMenuItemModel,
// shouldPersistOnRouteChange: true
// },
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection));
observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
// this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME)
).pipe(
// TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed; otherwise even in production mode, the metadata export button is only available after a refresh (and not in dev mode)
// filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists),
take(1)
).subscribe(() => {
this.menuService.addSection(this.menuID, {
id: 'export_metadata',
parentID: 'export',
active: true,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_metadata',
function: () => {
this.modalService.open(ExportMetadataSelectorComponent);
}
} as OnClickMenuItemModel,
shouldPersistOnRouteChange: true
});
});
}
/**
* Create menu sections dependent on whether or not the current user is a site administrator and on whether or not
* the import scripts exist and the current user is allowed to execute them
*/
createImportMenuSections() {
const menuList = [
/* Import */
{
id: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.import'
} as TextMenuItemModel,
icon: 'file-import',
index: 2
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'import_batch',
// parentID: 'import',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.import_batch',
// link: ''
// } as LinkMenuItemModel,
// }
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
// this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME)
).pipe(
// TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed
// filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists),
take(1)
).subscribe(() => {
this.menuService.addSection(this.menuID, {
id: 'import_metadata',
parentID: 'import',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_metadata',
link: '/admin/metadata-import'
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
});
});
}
/**
* Create menu sections dependent on whether or not the current user is a site administrator
*/
createSiteAdministratorMenuSections() {
this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => {
const menuList = [
/* Admin Search */
{
id: 'admin_search',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.admin_search',
link: '/admin/search'
} as LinkMenuItemModel,
icon: 'search',
index: 5
},
/* Registries */
{
id: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.registries'
} as TextMenuItemModel,
icon: 'list',
index: 6
},
{
id: 'registries_metadata',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_metadata',
link: 'admin/registries/metadata'
} as LinkMenuItemModel,
},
{
id: 'registries_format',
parentID: 'registries',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.registries_format',
link: 'admin/registries/bitstream-formats'
} as LinkMenuItemModel,
},
/* Curation tasks */
{
id: 'curation_tasks',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.curation_task',
link: 'admin/curation-tasks'
} as LinkMenuItemModel,
icon: 'filter',
index: 7
},
/* Workflow */
{
id: 'workflow',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.workflow',
link: '/admin/workflow'
} as LinkMenuItemModel,
icon: 'user-check',
index: 11
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true
})));
});
}
/**
* Create menu sections dependent on whether or not the current user can manage access control groups
*/
createAccessControlMenuSections() {
observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups)
).subscribe(([isSiteAdmin, canManageGroups]) => {
const menuList = [
/* Access Control */
{
id: 'access_control_people',
parentID: 'access_control',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_people',
link: '/access-control/epeople'
} as LinkMenuItemModel,
},
{
id: 'access_control_groups',
parentID: 'access_control',
active: false,
visible: canManageGroups,
model: {
type: MenuItemType.LINK,
text: 'menu.section.access_control_groups',
link: '/access-control/groups'
} as LinkMenuItemModel,
},
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'access_control_authorizations',
// parentID: 'access_control',
// active: false,
// visible: authorized,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.access_control_authorizations',
// link: ''
// } as LinkMenuItemModel,
// },
{
id: 'access_control',
active: false,
visible: canManageGroups || isSiteAdmin,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.access_control'
} as TextMenuItemModel,
icon: 'key',
index: 4
},
];
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true,
})));
});
}
@HostListener('focusin') @HostListener('focusin')
public handleFocusIn() { public handleFocusIn() {
this.inFocus$.next(true); this.inFocus$.next(true);

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component'; import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub'; 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 { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { Component } from '@angular/core'; import { Component } from '@angular/core';

View File

@@ -2,20 +2,20 @@ import { Component, Inject, Injector, OnInit } from '@angular/core';
import { rotate } from '../../../shared/animations/rotate'; import { rotate } from '../../../shared/animations/rotate';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component'; import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
import { slide } from '../../../shared/animations/slide'; import { slide } from '../../../shared/animations/slide';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { bgColor } from '../../../shared/animations/bgColor'; import { bgColor } from '../../../shared/animations/bgColor';
import { MenuID } from '../../../shared/menu/initial-menus-state';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
/** /**
* Represents a expandable section in the sidebar * Represents a expandable section in the sidebar
*/ */
@Component({ @Component({
/* tslint:disable:component-selector */ /* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-expandable-admin-sidebar-section]', selector: 'li[ds-expandable-admin-sidebar-section]',
templateUrl: './expandable-admin-sidebar-section.component.html', templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'], styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
@@ -65,7 +65,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit(); super.ngOnInit();
this.sidebarActiveBg = this.variableService.getVariable('adminSidebarActiveBg'); this.sidebarActiveBg = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID); this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID); this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed) this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed)

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { AdminSidebarComponent } from './admin-sidebar.component';
/**
* Themed wrapper for AdminSidebarComponent
*/
@Component({
selector: 'ds-themed-admin-sidebar',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
})
export class ThemedAdminSidebarComponent extends ThemedComponent<AdminSidebarComponent> {
protected getComponentName(): string {
return 'AdminSidebarComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/admin/admin-sidebar/admin-sidebar.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./admin-sidebar.component');
}
}

View File

@@ -18,6 +18,8 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock'; import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
describe('WorkflowItemAdminWorkflowListElementComponent', () => { describe('WorkflowItemAdminWorkflowListElementComponent', () => {
let component: WorkflowItemSearchResultAdminWorkflowListElementComponent; let component: WorkflowItemSearchResultAdminWorkflowListElementComponent;
@@ -51,7 +53,8 @@ describe('WorkflowItemAdminWorkflowListElementComponent', () => {
providers: [ providers: [
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
{ provide: DSONameService, useClass: DSONameServiceMock } { provide: DSONameService, useClass: DSONameServiceMock },
{ provide: APP_CONFIG, useValue: environment }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, Inject, OnInit } from '@angular/core';
import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { Context } from '../../../../../core/shared/context.model'; import { Context } from '../../../../../core/shared/context.model';
@@ -13,6 +13,7 @@ import { SearchResultListElementComponent } from '../../../../../shared/object-l
import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service';
import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model';
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
import { APP_CONFIG, AppConfig } from '../../../../../../config/app-config.interface';
@listableObjectComponent(WorkflowItemSearchResult, ViewMode.ListElement, Context.AdminWorkflowSearch) @listableObjectComponent(WorkflowItemSearchResult, ViewMode.ListElement, Context.AdminWorkflowSearch)
@Component({ @Component({
@@ -32,9 +33,10 @@ export class WorkflowItemSearchResultAdminWorkflowListElementComponent extends S
constructor(private linkService: LinkService, constructor(private linkService: LinkService,
protected truncatableService: TruncatableService, protected truncatableService: TruncatableService,
protected dsoNameService: DSONameService protected dsoNameService: DSONameService,
@Inject(APP_CONFIG) protected appConfig: AppConfig
) { ) {
super(truncatableService, dsoNameService); super(truncatableService, dsoNameService, appConfig);
} }
/** /**

View File

@@ -1,7 +1,8 @@
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.workflow.item.delete' | translate"> <div class="space-children-mr">
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.workflow.item.delete' | translate">
<i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.workflow.item.delete" | translate}}</span> <i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.workflow.item.delete" | translate}}</span>
</a> </a>
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 send-back-link" [routerLink]="[getSendBackRoute()]" [title]="'admin.workflow.item.send-back' | translate">
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 send-back-link" [routerLink]="[getSendBackRoute()]" [title]="'admin.workflow.item.send-back' | translate">
<i class="fa fa-hand-point-left"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.workflow.item.send-back" | translate}}</span> <i class="fa fa-hand-point-left"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.workflow.item.send-back" | translate}}</span>
</a> </a>
</div>

View File

@@ -9,6 +9,8 @@ import { AdminWorkflowModuleModule } from './admin-workflow-page/admin-workflow.
import { AdminSearchModule } from './admin-search-page/admin-search.module'; import { AdminSearchModule } from './admin-search-page/admin-search.module';
import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
import { UploadModule } from '../shared/upload/upload.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -24,11 +26,13 @@ const ENTRY_COMPONENTS = [
AccessControlModule, AccessControlModule,
AdminSearchModule.withEntryComponents(), AdminSearchModule.withEntryComponents(),
AdminWorkflowModuleModule.withEntryComponents(), AdminWorkflowModuleModule.withEntryComponents(),
SharedModule SharedModule,
UploadModule,
], ],
declarations: [ declarations: [
AdminCurationTasksComponent, AdminCurationTasksComponent,
MetadataImportPageComponent MetadataImportPageComponent,
BatchImportPageComponent
] ]
}) })
export class AdminModule { export class AdminModule {

View File

@@ -70,6 +70,12 @@ export function getWorkflowItemModuleRoute() {
return `/${WORKFLOW_ITEM_MODULE_PATH}`; 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 { export function getDSORoute(dso: DSpaceObject): string {
if (hasValue(dso)) { if (hasValue(dso)) {
switch ((dso as any).type) { switch ((dso as any).type) {
@@ -101,6 +107,8 @@ export function getPageInternalServerErrorRoute() {
return `/${INTERNAL_SERVER_ERROR}`; return `/${INTERNAL_SERVER_ERROR}`;
} }
export const ERROR_PAGE = 'error';
export const INFO_MODULE_PATH = 'info'; export const INFO_MODULE_PATH = 'info';
export function getInfoModulePath() { export function getInfoModulePath() {
return `/${INFO_MODULE_PATH}`; return `/${INFO_MODULE_PATH}`;
@@ -116,3 +124,5 @@ export const REQUEST_COPY_MODULE_PATH = 'request-a-copy';
export function getRequestCopyModulePath() { export function getRequestCopyModulePath() {
return `/${REQUEST_COPY_MODULE_PATH}`; return `/${REQUEST_COPY_MODULE_PATH}`;
} }
export const HEALTH_PAGE_PATH = 'health';

View File

@@ -1,15 +1,19 @@
import { NgModule } from '@angular/core'; 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 { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { AuthenticatedGuard } from './core/auth/authenticated.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 { import {
ACCESS_CONTROL_MODULE_PATH, ACCESS_CONTROL_MODULE_PATH,
ADMIN_MODULE_PATH, ADMIN_MODULE_PATH,
BITSTREAM_MODULE_PATH, BITSTREAM_MODULE_PATH,
ERROR_PAGE,
FORBIDDEN_PATH, FORBIDDEN_PATH,
FORGOT_PASSWORD_PATH, FORGOT_PASSWORD_PATH,
HEALTH_PAGE_PATH,
INFO_MODULE_PATH, INFO_MODULE_PATH,
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
LEGACY_BITSTREAM_MODULE_PATH, LEGACY_BITSTREAM_MODULE_PATH,
@@ -27,18 +31,26 @@ import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import {
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; 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 { ServerCheckGuard } from './core/server-check/server-check.guard';
import { MenuResolver } from './menu.resolver';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot([ RouterModule.forRoot([
{ path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent }, { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent },
{ path: ERROR_PAGE , component: ThemedPageErrorComponent },
{ {
path: '', path: '',
canActivate: [AuthBlockingGuard], canActivate: [AuthBlockingGuard],
canActivateChild: [ServerCheckGuard], canActivateChild: [ServerCheckGuard],
resolve: [MenuResolver],
children: [ children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ {
@@ -208,6 +220,11 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
loadChildren: () => import('./statistics-page/statistics-page-routing.module') loadChildren: () => import('./statistics-page/statistics-page-routing.module')
.then((m) => m.StatisticsPageRoutingModule) .then((m) => m.StatisticsPageRoutingModule)
}, },
{
path: HEALTH_PAGE_PATH,
loadChildren: () => import('./health-page/health-page.module')
.then((m) => m.HealthPageModule)
},
{ {
path: ACCESS_CONTROL_MODULE_PATH, path: ACCESS_CONTROL_MODULE_PATH,
loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule),
@@ -217,6 +234,12 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard';
] ]
} }
], { ], {
// enableTracing: true,
useHash: false,
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
initialNavigation: 'enabledBlocking',
preloadingStrategy: NoPreloading,
onSameUrlNavigation: 'reload', onSameUrlNavigation: 'reload',
}) })
], ],

View File

@@ -1,10 +1,9 @@
import { Store, StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule, DOCUMENT } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
// Load the implementations that should be tested // Load the implementations that should be tested
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
@@ -19,7 +18,7 @@ import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.ser
import { AuthServiceMock } from './shared/mocks/auth.service.mock'; import { AuthServiceMock } from './shared/mocks/auth.service.mock';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { MenuService } from './shared/menu/menu.service'; import { MenuService } from './shared/menu/menu.service';
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 { CSSVariableServiceStub } from './shared/testing/css-variable-service.stub';
import { MenuServiceStub } from './shared/testing/menu-service.stub'; import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { HostWindowService } from './shared/host-window.service'; import { HostWindowService } from './shared/host-window.service';
@@ -32,7 +31,6 @@ import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
@@ -46,16 +44,16 @@ const initialState = {
core: { auth: { loading: false } } core: { auth: { loading: false } }
}; };
export function getMockLocaleService(): LocaleService {
return jasmine.createSpyObj('LocaleService', {
setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode')
});
}
describe('App component', () => { describe('App component', () => {
let breadcrumbsServiceSpy; let breadcrumbsServiceSpy;
function getMockLocaleService(): LocaleService {
return jasmine.createSpyObj('LocaleService', {
setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode')
});
}
const getDefaultTestBedConf = () => { const getDefaultTestBedConf = () => {
breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']); breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']);
@@ -74,7 +72,6 @@ describe('App component', () => {
providers: [ providers: [
{ provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: NativeWindowService, useValue: new NativeWindowRef() },
{ provide: MetadataService, useValue: new MetadataServiceMock() }, { provide: MetadataService, useValue: new MetadataServiceMock() },
{ provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() },
{ provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() },
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
@@ -130,66 +127,4 @@ describe('App component', () => {
}); });
}); });
describe('the constructor', () => {
it('should call breadcrumbsService.listenForRouteChanges', () => {
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
});
});
describe('when GoogleAnalyticsService is provided', () => {
let googleAnalyticsSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [
'addTrackingIdToPage',
]);
TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy});
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should create component', () => {
expect(comp).toBeTruthy();
});
describe('the constructor', () => {
it('should call googleAnalyticsService.addTrackingIdToPage()', () => {
expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1);
});
});
});
describe('when ThemeService returns a custom theme', () => {
let document;
let headSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')});
document = TestBed.inject(DOCUMENT);
headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']);
headSpy.getElementsByClassName.and.returnValue([]);
spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]);
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should append a link element with the correct attributes to the head element', () => {
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', '/custom-theme.css');
expect(headSpy.appendChild).toHaveBeenCalledWith(link);
});
});
}); });

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