Merge branch 'main' of https://github.com/DSpace/dspace-angular into CST-11298

This commit is contained in:
Francesco Bacchelli
2023-08-01 15:51:02 +02:00
1375 changed files with 90146 additions and 41293 deletions

View File

@@ -1,17 +0,0 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
[*.ts] [*.ts]
quote_type = single quote_type = single
[*.json5]
ij_json_keep_blank_lines_in_code = 3

View File

@@ -7,7 +7,8 @@
"eslint-plugin-jsdoc", "eslint-plugin-jsdoc",
"eslint-plugin-deprecation", "eslint-plugin-deprecation",
"unused-imports", "unused-imports",
"eslint-plugin-lodash" "eslint-plugin-lodash",
"eslint-plugin-jsonc"
], ],
"overrides": [ "overrides": [
{ {
@@ -224,6 +225,42 @@
"@angular-eslint/template/no-negated-async": "off", "@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off" "@angular-eslint/template/eqeqeq": "off"
} }
},
{
"files": [
"*.json5"
],
"extends": [
"plugin:jsonc/recommended-with-jsonc"
],
"rules": {
"no-irregular-whitespace": "error",
"no-trailing-spaces": "error",
"jsonc/comma-dangle": [
"error",
"always-multiline"
],
"jsonc/indent": [
"error",
2
],
"jsonc/key-spacing": [
"error",
{
"beforeColon": false,
"afterColon": true,
"mode": "strict"
}
],
"jsonc/no-dupe-keys": "off",
"jsonc/quotes": [
"error",
"double",
{
"avoidEscape": false
}
]
}
} }
] ]
} }

View File

@@ -1,26 +0,0 @@
# This workflow runs whenever a new pull request is created
# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs).
# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818
name: Pull Request opened
# Only run for newly opened PRs against the "main" branch
on:
pull_request:
types: [opened]
branches:
- main
jobs:
automation:
runs-on: ubuntu-latest
steps:
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
# See https://github.com/marketplace/actions/pull-request-assigner
- name: Assign PR to creator
uses: thomaseizinger/assign-pr-creator-action@v1.0.0
# Note, this authentication token is created automatically
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Ignore errors. It is possible the PR was created by someone who cannot be assigned
continue-on-error: true

View File

@@ -15,15 +15,24 @@ jobs:
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.
# NOTE: These settings should be kept in sync with those in [src]/docker/docker-compose-ci.yml
DSPACE_REST_HOST: 127.0.0.1 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+ # 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 DSPACE_UI_HOST: 127.0.0.1
DSPACE_UI_PORT: 4000
# Ensure all SSR caching is disabled in test environment
DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0
DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0
# Tell Cypress to run e2e tests using the same UI URL
CYPRESS_BASE_URL: http://127.0.0.1:4000
# 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"
# Bump Node heap size (OOM in CI after upgrading to Angular 15)
NODE_OPTIONS: '--max-old-space-size=4096'
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:
@@ -61,7 +70,7 @@ jobs:
# https://github.com/actions/cache/blob/main/examples.md#node---yarn # https://github.com/actions/cache/blob/main/examples.md#node---yarn
- name: Get Yarn cache directory - name: Get Yarn cache directory
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache Yarn dependencies - name: Cache Yarn dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
@@ -86,12 +95,16 @@ jobs:
- name: Run specs (unit tests) - name: Run specs (unit tests)
run: yarn run test:headless run: yarn run test:headless
# Upload code coverage report to artifact (for one version of Node only),
# so that it can be shared with the 'codecov' job (see below)
# 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 one version of Node only) - name: Upload code coverage report to Artifact
# https://github.com/codecov/codecov-action uses: actions/upload-artifact@v3
- name: Upload coverage to Codecov.io if: matrix.node-version == '18.x'
uses: codecov/codecov-action@v3 with:
if: matrix.node-version == '16.x' name: dspace-angular coverage report
path: 'coverage/dspace-angular/lcov.info'
retention-days: 14
# 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
@@ -105,11 +118,10 @@ 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@v4 uses: cypress-io/github-action@v5
with: with:
# Run tests in Chrome, headless mode # Run tests in Chrome, headless mode (default)
browser: chrome browser: chrome
headless: true
# Start app before running tests (will be stopped automatically after tests finish) # Start app before running tests (will be stopped automatically after tests finish)
start: yarn run serve:ssr start: yarn run serve:ssr
# Wait for backend & frontend to be available # Wait for backend & frontend to be available
@@ -169,3 +181,32 @@ jobs:
- name: Shutdown Docker containers - name: Shutdown Docker containers
run: docker-compose -f ./docker/docker-compose-ci.yml down run: docker-compose -f ./docker/docker-compose-ci.yml down
# Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
# job above. This is necessary because Codecov uploads seem to randomly fail at times.
# See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954
codecov:
# Must run after 'tests' job above
needs: tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
# Download artifacts from previous 'tests' job
- name: Download coverage artifacts
uses: actions/download-artifact@v3
# Now attempt upload to Codecov using its action.
# NOTE: We use a retry action to retry the Codecov upload if it fails the first time.
#
# Retry action: https://github.com/marketplace/actions/retry-action
# Codecov action: https://github.com/codecov/codecov-action
- name: Upload coverage to Codecov.io
uses: Wandalen/wretry.action@v1.0.36
with:
action: codecov/codecov-action@v3
# Try upload 5 times max
attempt_limit: 5
# Run again in 30 seconds
attempt_delay: 30000

View File

@@ -5,12 +5,16 @@
# because CodeQL requires a fresh build with all tests *disabled*. # because CodeQL requires a fresh build with all tests *disabled*.
name: "Code Scanning" name: "Code Scanning"
# Run this code scan for all pushes / PRs to main branch. Also run once a week. # Run this code scan for all pushes / PRs to main or maintenance branches. Also run once a week.
on: on:
push: push:
branches: [ main ] branches:
- main
- 'dspace-**'
pull_request: pull_request:
branches: [ main ] branches:
- main
- 'dspace-**'
# Don't run if PR is only updating static documentation # Don't run if PR is only updating static documentation
paths-ignore: paths-ignore:
- '**/*.md' - '**/*.md'

View File

@@ -15,29 +15,35 @@ on:
permissions: permissions:
contents: read # to fetch code (actions/checkout) contents: read # to fetch code (actions/checkout)
env:
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
# For a new commit on default branch (main), use the literal tag 'latest' on Docker image.
# For a new commit on other branches, use the branch name as the tag for Docker image.
# For a new tag, copy that tag name as the tag for Docker image.
IMAGE_TAGS: |
type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=tag
# Define default tag "flavor" for docker/metadata-action per
# https://github.com/docker/metadata-action#flavor-input
# We manage the 'latest' tag ourselves to the 'main' branch (see settings above)
TAGS_FLAVOR: |
latest=false
# Architectures / Platforms for which we will build Docker images
# If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work.
# If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64.
PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }}
jobs: jobs:
docker: ###############################################
# Build/Push the 'dspace/dspace-angular' image
###############################################
dspace-angular:
# 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'
if: github.repository == 'dspace/dspace-angular' if: github.repository == 'dspace/dspace-angular'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action)
# For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image.
# For a new commit on other branches, use the branch name as the tag for Docker image.
# For a new tag, copy that tag name as the tag for Docker image.
IMAGE_TAGS: |
type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }}
type=ref,event=tag
# Define default tag "flavor" for docker/metadata-action per
# https://github.com/docker/metadata-action#flavor-input
# We turn off 'latest' tag by default.
TAGS_FLAVOR: |
latest=false
# 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
@@ -61,9 +67,6 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
###############################################
# Build/Push the 'dspace/dspace-angular' image
###############################################
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
# 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
@@ -77,7 +80,7 @@ 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@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -88,3 +91,60 @@ jobs:
# Use tags / labels provided by 'docker/metadata-action' above # Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build.outputs.tags }} tags: ${{ steps.meta_build.outputs.tags }}
labels: ${{ steps.meta_build.outputs.labels }} labels: ${{ steps.meta_build.outputs.labels }}
#############################################################
# Build/Push the 'dspace/dspace-angular' image ('-dist' tag)
#############################################################
dspace-angular-dist:
# 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
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
uses: actions/checkout@v3
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx
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
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
# https://github.com/docker/metadata-action
# Get Metadata for docker_build_dist step below
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image
id: meta_build_dist
uses: docker/metadata-action@v4
with:
images: dspace/dspace-angular
tags: ${{ env.IMAGE_TAGS }}
# As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
# tagging logic as the primary 'dspace/dspace-angular' image above.
flavor: ${{ env.TAGS_FLAVOR }}
suffix=-dist
- name: Build and push 'dspace-angular-dist' image
id: docker_build_dist
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.dist
platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
# Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build_dist.outputs.tags }}
labels: ${{ steps.meta_build_dist.outputs.labels }}

View File

@@ -16,7 +16,7 @@ jobs:
# 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: actions/add-to-project@v0.3.0 uses: actions/add-to-project@v0.5.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 admin:org, project, public_repo 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

View File

@@ -1,11 +1,12 @@
# This workflow checks open PRs for merge conflicts and labels them when conflicts are found # This workflow checks open PRs for merge conflicts and labels them when conflicts are found
name: Check for merge conflicts name: Check for merge conflicts
# Run whenever the "main" branch is updated # Run this for all pushes (i.e. merges) to 'main' or maintenance branches
# NOTE: This means merge conflicts are only checked for when a PR is merged to main.
on: on:
push: push:
branches: [ main ] branches:
- main
- 'dspace-**'
# So that the `conflict_label_name` is removed if conflicts are resolved, # 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. # we allow this to run for `pull_request_target` so that github secrets are available.
pull_request_target: pull_request_target:
@@ -23,7 +24,9 @@ jobs:
steps: steps:
# See: https://github.com/prince-chrismc/label-merge-conflicts-action # 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: prince-chrismc/label-merge-conflicts-action@v2 uses: prince-chrismc/label-merge-conflicts-action@v3
# Ignore any failures -- may occur (randomly?) for older, outdated PRs.
continue-on-error: true
# 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

View File

@@ -0,0 +1,44 @@
# This workflow will attempt to port a merged pull request to
# the branch specified in a "port to" label (if exists)
name: Port merged Pull Request
# Only run for merged PRs against the "main" or maintenance branches
# We allow this to run for `pull_request_target` so that github secrets are available
# (This is required when the PR comes from a forked repo)
on:
pull_request_target:
types: [ closed ]
branches:
- main
- 'dspace-**'
permissions:
contents: write # so action can add comments
pull-requests: write # so action can create pull requests
jobs:
port_pr:
runs-on: ubuntu-latest
# Don't run on closed *unmerged* pull requests
if: github.event.pull_request.merged
steps:
# Checkout code
- uses: actions/checkout@v3
# Port PR to other branch (ONLY if labeled with "port to")
# See https://github.com/korthout/backport-action
- name: Create backport pull requests
uses: korthout/backport-action@v1
with:
# Trigger based on a "port to [branch]" label on PR
# (This label must specify the branch name to port to)
label_pattern: '^port to ([^ ]+)$'
# Title to add to the (newly created) port PR
pull_title: '[Port ${target_branch}] ${pull_title}'
# Description to add to the (newly created) port PR
pull_description: 'Port of #${pull_number} by @${pull_author} to `${target_branch}`.'
# Copy all labels from original PR to (newly created) port PR
# NOTE: The labels matching 'label_pattern' are automatically excluded
copy_labels_pattern: '.*'
# Use a personal access token (PAT) to create PR as 'dspace-bot' user.
# A PAT is required in order for the new PR to trigger its own actions (for CI checks)
github_token: ${{ secrets.PR_PORT_TOKEN }}

View File

@@ -0,0 +1,24 @@
# This workflow runs whenever a new pull request is created
name: Pull Request opened
# Only run for newly opened PRs against the "main" or maintenance branches
# We allow this to run for `pull_request_target` so that github secrets are available
# (This is required to assign a PR back to the creator when the PR comes from a forked repo)
on:
pull_request_target:
types: [ opened ]
branches:
- main
- 'dspace-**'
permissions:
pull-requests: write
jobs:
automation:
runs-on: ubuntu-latest
steps:
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
# See https://github.com/toshimaru/auto-author-assign
- name: Assign PR to creator
uses: toshimaru/auto-author-assign@v1.6.2

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ package-lock.json
/nbproject/ /nbproject/
junit.xml junit.xml
/src/mirador-viewer/config.local.js

View File

@@ -2,20 +2,27 @@
# 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:18-alpine FROM node:18-alpine
WORKDIR /app
ADD . /app/
EXPOSE 4000
# Ensure Python and other build tools are available # Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64 # These are needed to install some node modules, especially on linux/arm64
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
WORKDIR /app
ADD . /app/
EXPOSE 4000
# 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
# When running in dev mode, 4GB of memory is required to build & launch the app.
# This default setting can be overridden as needed in your shell, via an env file or in docker-compose.
# See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/
ENV NODE_OPTIONS="--max_old_space_size=4096"
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc). # On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
# Listen / accept connections from all IP addresses. # Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode # 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 # if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
ENV NODE_ENV development
CMD yarn serve --host 0.0.0.0 CMD yarn serve --host 0.0.0.0

31
Dockerfile.dist Normal file
View File

@@ -0,0 +1,31 @@
# This image will be published as dspace/dspace-angular:$DSPACE_VERSION-dist
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
# Test build:
# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
FROM node:18-alpine as build
# 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/*
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --network-timeout 300000
ADD . /app/
RUN yarn build:prod
FROM node:18-alpine
RUN npm install --global pm2
COPY --chown=node:node --from=build /app/dist /app/dist
COPY --chown=node:node config /app/config
COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
WORKDIR /app
USER node
ENV NODE_ENV production
EXPOSE 4000
CMD pm2-runtime start dspace-ui.json --json

View File

@@ -413,8 +413,7 @@ dspace-angular
│ ├── merge-i18n-files.ts * │ ├── merge-i18n-files.ts *
│ ├── serve.ts * │ ├── serve.ts *
│ ├── sync-i18n-files.ts * │ ├── sync-i18n-files.ts *
── test-rest.ts * ── test-rest.ts *
│ └── webpack.js *
├── src * The source of the application ├── src * The source of the application
│ ├── app * The source code of the application, subdivided by module/page. │ ├── app * The source code of the application, subdivided by module/page.
│ ├── assets * Folder for static resources │ ├── assets * Folder for static resources

View File

@@ -266,16 +266,26 @@
"options": { "options": {
"lintFilePatterns": [ "lintFilePatterns": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.html" "src/**/*.html",
"src/**/*.json5"
] ]
} }
} }
} }
} }
}, },
"defaultProject": "dspace-angular",
"cli": { "cli": {
"analytics": false, "analytics": false,
"defaultCollection": "@angular-eslint/schematics" "schematicCollections": [
"@angular-eslint/schematics"
]
},
"schematics": {
"@angular-eslint/schematics:application": {
"setParserOptionsProject": true
},
"@angular-eslint/schematics:library": {
"setParserOptionsProject": true
}
} }
} }

View File

@@ -32,12 +32,60 @@ cache:
# NOTE: how long should objects be cached for by default # NOTE: how long should objects be cached for by default
msToLive: msToLive:
default: 900000 # 15 minutes default: 900000 # 15 minutes
control: max-age=60 # revalidate browser # Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files)
# Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these
# files for one week, after which they will be "stale" and need to be redownloaded.
# NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because
# all compiled *.js files include a unique hash in their name which updates when content is modified.
control: max-age=604800 # revalidate browser
autoSync: autoSync:
defaultTime: 0 defaultTime: 0
maxBufferSize: 100 maxBufferSize: 100
timePerMethod: timePerMethod:
PATCH: 3 # time in seconds PATCH: 3 # time in seconds
# In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages.
# Pages are automatically added/dropped from these caches based on how recently they have been used.
# Restarting the app clears all page caches.
# NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB).
# Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive).
serverSide:
# Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues.
debug: false
# When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots.
# (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.)
botCache:
# Maximum number of pages to cache for known bots. Set to zero (0) to disable server side caching for bots.
# Default is 1000, which means the 1000 most recently accessed public pages will be cached.
# As all pages are cached in server memory, increasing this value will increase memory needs.
# Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory.
max: 1000
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
# copy is automatically refreshed on the next request.
# NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site.
# For example, setting this to one week may mean that search engine bots may not find all new content for one week.
timeToLive: 86400000 # 1 day
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
allowStale: true
# When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache.
# This allows anonymous users to interact more quickly with the site, but also means they may see slightly
# outdated content (based on timeToLive)
anonymousCache:
# Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled.
# As all pages are cached in server memory, increasing this value will increase memory needs.
# Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory.
max: 0
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
# copy is automatically refreshed on the next request.
# NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content.
timeToLive: 10000 # 10 seconds
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
allowStale: true
# Authentication settings # Authentication settings
auth: auth:
@@ -121,6 +169,9 @@ languages:
- code: en - code: en
label: English label: English
active: true active: true
- code: ca
label: Català
active: true
- code: cs - code: cs
label: Čeština label: Čeština
active: true active: true
@@ -136,6 +187,9 @@ languages:
- code: gd - code: gd
label: Gàidhlig label: Gàidhlig
active: true active: true
- code: it
label: Italiano
active: true
- code: lv - code: lv
label: Latviešu label: Latviešu
active: true active: true
@@ -163,6 +217,9 @@ languages:
- code: tr - code: tr
label: Türkçe label: Türkçe
active: true active: true
- code: vi
label: Tiếng Việt
active: true
- code: kk - code: kk
label: Қазақ label: Қазақ
active: true active: true
@@ -318,3 +375,8 @@ vocabularies:
- filter: 'subject' - filter: 'subject'
vocabulary: 'srsc' vocabulary: 'srsc'
enabled: true enabled: true
# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
comcolSelectionSort:
sortField: 'dc.title'
sortDirection: 'ASC'

44
cypress.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'cypress';
export default defineConfig({
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
retries: {
runMode: 2,
openMode: 0,
},
env: {
// Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts)
// May be overridden in our cypress.json config file using specified environment variables.
// Default values listed here are all valid for the Demo Entities Data set available at
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment)
// Admin account used for administrative tests
DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com',
DSPACE_TEST_ADMIN_PASSWORD: 'dspace',
// Community/collection/publication used for view/edit tests
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',
// Search term (should return results) used in search tests
DSPACE_TEST_SEARCH_TERM: 'test',
// Collection used for submission tests
DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
// Account used to test basic submission process
DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',
},
e2e: {
// Setup our plugins for e2e tests
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.ts')(on, config);
},
// This is the base URL that Cypress will run all tests against
// It can be overridden via the CYPRESS_BASE_URL environment variable
// (By default we set this to a value which should work in most development environments)
baseUrl: 'http://localhost:4000',
},
});

View File

@@ -1,25 +0,0 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://127.0.0.1:4000",
"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"
}
}

View File

@@ -1,10 +1,10 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Breadcrumbs', () => { describe('Breadcrumbs', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
// Visit an Item, as those have more breadcrumbs // Visit an Item, as those have more breadcrumbs
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION); cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
// Wait for breadcrumbs to be visible // Wait for breadcrumbs to be visible
cy.get('ds-breadcrumbs').should('be.visible'); cy.get('ds-breadcrumbs').should('be.visible');

View File

@@ -1,13 +1,13 @@
import { TEST_COLLECTION } from 'cypress/support'; import { TEST_COLLECTION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Collection Page', () => { describe('Collection Page', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.visit('/collections/' + TEST_COLLECTION); cy.visit('/collections/'.concat(TEST_COLLECTION));
// <ds-collection-page> tag must be loaded // <ds-collection-page> tag must be loaded
cy.get('ds-collection-page').should('exist'); cy.get('ds-collection-page').should('be.visible');
// Analyze <ds-collection-page> for accessibility issues // Analyze <ds-collection-page> for accessibility issues
testA11y('ds-collection-page'); testA11y('ds-collection-page');

View File

@@ -0,0 +1,37 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Collection Statistics Page', () => {
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION);
it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/'.concat(TEST_COLLECTION));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
cy.get('table[data-test="TotalVisits"]').should('be.visible');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
// Check just for existence because this table is empty in CI environment as it's historical data
cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
// <ds-collection-statistics-page> tag must be loaded
cy.get('ds-collection-statistics-page').should('be.visible');
// Verify / wait until "Total Visits" table's label is non-empty
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
// Analyze <ds-collection-statistics-page> for accessibility issues
testA11y('ds-collection-statistics-page');
});
});

View File

@@ -7,10 +7,10 @@ describe('Community List Page', () => {
cy.visit('/community-list'); cy.visit('/community-list');
// <ds-community-list-page> tag must be loaded // <ds-community-list-page> tag must be loaded
cy.get('ds-community-list-page').should('exist'); cy.get('ds-community-list-page').should('be.visible');
// Open first Community (to show Collections)...that way we scan sub-elements as well // Open every expand button on page, so that we can scan sub-elements as well
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click(); cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze <ds-community-list-page> for accessibility issues // Analyze <ds-community-list-page> for accessibility issues
// Disable heading-order checks until it is fixed // Disable heading-order checks until it is fixed

View File

@@ -1,13 +1,13 @@
import { TEST_COMMUNITY } from 'cypress/support'; import { TEST_COMMUNITY } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Community Page', () => { describe('Community Page', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.visit('/communities/' + TEST_COMMUNITY); cy.visit('/communities/'.concat(TEST_COMMUNITY));
// <ds-community-page> tag must be loaded // <ds-community-page> tag must be loaded
cy.get('ds-community-page').should('exist'); cy.get('ds-community-page').should('be.visible');
// Analyze <ds-community-page> for accessibility issues // Analyze <ds-community-page> for accessibility issues
testA11y('ds-community-page',); testA11y('ds-community-page',);

View File

@@ -0,0 +1,37 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
describe('Community Statistics Page', () => {
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY);
it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/'.concat(TEST_COMMUNITY));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
cy.get('table[data-test="TotalVisits"]').should('be.visible');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
// Check just for existence because this table is empty in CI environment as it's historical data
cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
// <ds-community-statistics-page> tag must be loaded
cy.get('ds-community-statistics-page').should('be.visible');
// Verify / wait until "Total Visits" table's label is non-empty
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
// Analyze <ds-community-statistics-page> for accessibility issues
testA11y('ds-community-statistics-page');
});
});

View File

@@ -0,0 +1,31 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
import '../support/commands';
describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', '/statistics');
});
it('should pass accessibility tests', () => {
// generate 2 view events on an Item's page
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
cy.visit('/statistics');
// <ds-site-statistics-page> tag must be visable
cy.get('ds-site-statistics-page').should('be.visible');
// Verify / wait until "Total Visits" table's *last* label is non-empty
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT);
// Wait an extra 500ms, just so all entries in Total Visits have loaded.
cy.wait(500);
// Analyze <ds-site-statistics-page> for accessibility issues
testA11y('ds-site-statistics-page');
});
});

View File

@@ -6,8 +6,8 @@ describe('Homepage', () => {
cy.visit('/'); cy.visit('/');
}); });
it('should display translated title "DSpace Angular :: Home"', () => { it('should display translated title "DSpace Repository :: Home"', () => {
cy.title().should('eq', 'DSpace Angular :: Home'); cy.title().should('eq', 'DSpace Repository :: Home');
}); });
it('should contain a news section', () => { it('should contain a news section', () => {

View File

@@ -1,10 +1,10 @@
import { Options } from 'cypress-axe'; import { Options } from 'cypress-axe';
import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Item Page', () => { describe('Item Page', () => {
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION; const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION);
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
it('should redirect to the entity page when navigating to an item page', () => { it('should redirect to the entity page when navigating to an item page', () => {
@@ -16,7 +16,7 @@ describe('Item Page', () => {
cy.visit(ENTITYPAGE); cy.visit(ENTITYPAGE);
// <ds-item-page> tag must be loaded // <ds-item-page> tag must be loaded
cy.get('ds-item-page').should('exist'); cy.get('ds-item-page').should('be.visible');
// Analyze <ds-item-page> for accessibility issues // Analyze <ds-item-page> for accessibility issues
// Disable heading-order checks until it is fixed // Disable heading-order checks until it is fixed

View File

@@ -1,36 +1,41 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Item Statistics Page', () => { describe('Item Statistics Page', () => {
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION; const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION);
it('should load if you click on "Statistics" from an Item/Entity page', () => { it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION); cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
}); });
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
cy.get('ds-item-statistics-page').should('exist'); cy.get('ds-item-statistics-page').should('be.visible');
cy.get('ds-item-page').should('not.exist'); cy.get('ds-item-page').should('not.exist');
}); });
it('should contain a "Total visits" section', () => { it('should contain a "Total visits" section', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist'); cy.get('table[data-test="TotalVisits"]').should('be.visible');
}); });
it('should contain a "Total visits per month" section', () => { it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist'); // Check just for existence because this table is empty in CI environment as it's historical data
cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
// <ds-item-statistics-page> tag must be loaded // <ds-item-statistics-page> tag must be loaded
cy.get('ds-item-statistics-page').should('exist'); cy.get('ds-item-statistics-page').should('be.visible');
// Verify / wait until "Total Visits" table's label is non-empty
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
// Analyze <ds-item-statistics-page> for accessibility issues // Analyze <ds-item-statistics-page> for accessibility issues
testA11y('ds-item-statistics-page'); testA11y('ds-item-statistics-page');

View File

@@ -1,4 +1,4 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support'; import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
const page = { const page = {
openLoginMenu() { openLoginMenu() {
@@ -36,7 +36,7 @@ const page = {
describe('Login Modal', () => { describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => { it('should login when clicking button & stay on same page', () => {
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
cy.visit(ENTITYPAGE); cy.visit(ENTITYPAGE);
// Login menu should exist // Login menu should exist

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe'; import { Options } from 'cypress-axe';
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support'; import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('My DSpace page', () => { describe('My DSpace page', () => {
@@ -9,7 +9,7 @@ describe('My DSpace page', () => {
// This page is restricted, so we will be shown the login form. Fill it out & submit. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.get('ds-my-dspace-page').should('exist'); cy.get('ds-my-dspace-page').should('be.visible');
// At least one recent submission should be displayed // At least one recent submission should be displayed
cy.get('[data-test="list-object"]').should('be.visible'); cy.get('[data-test="list-object"]').should('be.visible');
@@ -42,12 +42,12 @@ describe('My DSpace page', () => {
// This page is restricted, so we will be shown the login form. Fill it out & submit. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
cy.get('ds-my-dspace-page').should('exist'); cy.get('ds-my-dspace-page').should('be.visible');
// Click button in sidebar to display detailed view // Click button in sidebar to display detailed view
cy.get('ds-search-sidebar [data-test="detail-view"]').click(); cy.get('ds-search-sidebar [data-test="detail-view"]').click();
cy.get('ds-object-detail').should('exist'); cy.get('ds-object-detail').should('be.visible');
// Analyze <ds-search-page> for accessibility issues // Analyze <ds-search-page> for accessibility issues
testA11y('ds-my-dspace-page', testA11y('ds-my-dspace-page',
@@ -80,7 +80,7 @@ describe('My DSpace page', () => {
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
// Click on the button matching that known Collection name // Click on the button matching that known Collection name
cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click(); cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click();
// New URL should include /workspaceitems, as we've started a new submission // New URL should include /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems'); cy.url().should('include', '/workspaceitems');

View File

@@ -2,7 +2,7 @@ describe('PageNotFound', () => {
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid) // request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
cy.get('ds-pagenotfound').should('exist'); cy.get('ds-pagenotfound').should('be.visible');
}); });
it('should not contain element ds-pagenotfound when navigating to existing page', () => { it('should not contain element ds-pagenotfound when navigating to existing page', () => {

View File

@@ -1,4 +1,4 @@
import { TEST_SEARCH_TERM } from 'cypress/support'; import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
const page = { const page = {
fillOutQueryInNavBar(query) { fillOutQueryInNavBar(query) {
@@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => {
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='.concat(query));
// Wait for search results to come back from the above GET command // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
@@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => {
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='.concat(query));
// Wait for search results to come back from the above GET command // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed
@@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => {
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='.concat(query));
// Wait for search results to come back from the above GET command // Wait for search results to come back from the above GET command
cy.wait('@search-results'); cy.wait('@search-results');
// At least one search result should be displayed // At least one search result should be displayed

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe'; import { Options } from 'cypress-axe';
import { TEST_SEARCH_TERM } from 'cypress/support'; import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => { describe('Search Page', () => {
@@ -13,11 +13,11 @@ describe('Search Page', () => {
}); });
it('should load results and pass accessibility tests', () => { it('should load results and pass accessibility tests', () => {
cy.visit('/search?query=' + TEST_SEARCH_TERM); cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
cy.get('[data-test="search-box"]').should('have.value', 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('be.visible');
// At least one search result should be displayed // At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible'); cy.get('[data-test="list-object"]').should('be.visible');
@@ -45,13 +45,13 @@ describe('Search Page', () => {
}); });
it('should have a working grid view that passes accessibility tests', () => { it('should have a working grid view that passes accessibility tests', () => {
cy.visit('/search?query=' + TEST_SEARCH_TERM); cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
// Click button in sidebar to display grid view // Click button in sidebar to display grid view
cy.get('ds-search-sidebar [data-test="grid-view"]').click(); cy.get('ds-search-sidebar [data-test="grid-view"]').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('be.visible');
// At least one grid object (card) should be displayed // At least one grid object (card) should be displayed
cy.get('[data-test="grid-object"]').should('be.visible'); cy.get('[data-test="grid-object"]').should('be.visible');

View File

@@ -1,13 +1,11 @@
import { Options } from 'cypress-axe'; import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e';
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', () => { describe('New Submission page', () => {
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts // 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', () => { it('should create a new submission when using /submit path & pass accessibility', () => {
// Test that calling /submit with collection & entityType will create a new submission // Test that calling /submit with collection & entityType will create a new submission
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
// This page is restricted, so we will be shown the login form. Fill it out & submit. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
@@ -35,7 +33,7 @@ describe('New Submission page', () => {
it('should block submission & show errors if required fields are missing', () => { it('should block submission & show errors if required fields are missing', () => {
// Create a new submission // Create a new submission
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
// This page is restricted, so we will be shown the login form. Fill it out & submit. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
@@ -95,7 +93,7 @@ describe('New Submission page', () => {
it('should allow for deposit if all required fields completed & file uploaded', () => { it('should allow for deposit if all required fields completed & file uploaded', () => {
// Create a new submission // Create a new submission
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
// This page is restricted, so we will be shown the login form. Fill it out & submit. // 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.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
@@ -124,8 +122,6 @@ describe('New Submission page', () => {
// Wait for upload to complete before proceeding // Wait for upload to complete before proceeding
cy.wait('@upload'); 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. // Wait for deposit button to not be disabled & click it.
cy.get('button#deposit').should('not.be.disabled').click(); cy.get('button#deposit').should('not.be.disabled').click();

View File

@@ -1,32 +0,0 @@
import { TEST_COLLECTION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Collection Statistics Page', () => {
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/' + TEST_COLLECTION);
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
// <ds-collection-statistics-page> tag must be loaded
cy.get('ds-collection-statistics-page').should('exist');
// Analyze <ds-collection-statistics-page> for accessibility issues
testA11y('ds-collection-statistics-page');
});
});

View File

@@ -1,32 +0,0 @@
import { TEST_COMMUNITY } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Community Statistics Page', () => {
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/' + TEST_COMMUNITY);
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
// <ds-community-statistics-page> tag must be loaded
cy.get('ds-community-statistics-page').should('exist');
// Analyze <ds-community-statistics-page> for accessibility issues
testA11y('ds-community-statistics-page');
});
});

View File

@@ -1,19 +0,0 @@
import { testA11y } from 'cypress/support/utils';
describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', '/statistics');
});
it('should pass accessibility tests', () => {
cy.visit('/statistics');
// <ds-site-statistics-page> tag must be loaded
cy.get('ds-site-statistics-page').should('exist');
// Analyze <ds-site-statistics-page> for accessibility issues
testA11y('ds-site-statistics-page');
});
});

View File

@@ -4,12 +4,17 @@
// *********************************************** // ***********************************************
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
import { FALLBACK_TEST_REST_BASE_URL } from '.'; import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
// 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 'login()'.
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
export const FALLBACK_TEST_REST_DOMAIN = 'localhost';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs // Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work // ALL custom commands MUST be listed here for code completion to work
// tslint:disable-next-line:no-namespace
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress { namespace Cypress {
interface Chainable<Subject = any> { interface Chainable<Subject = any> {
/** /**
@@ -27,6 +32,15 @@ declare global {
* @param password password to login as * @param password password to login as
*/ */
loginViaForm(email: string, password: string): typeof loginViaForm; loginViaForm(email: string, password: string): typeof loginViaForm;
/**
* Generate view event for given object. Useful for testing statistics pages with
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
* generate multiple hits.
* @param uuid UUID of object
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/
generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
} }
} }
} }
@@ -53,52 +67,57 @@ function login(email: string, password: string): void {
if (!config.rest.baseUrl) { if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else { } else {
console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl); //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
baseRestUrl = config.rest.baseUrl; baseRestUrl = config.rest.baseUrl;
} }
// To login via REST, first we have to do a GET to obtain a valid CSRF token // Now find domain of our REST API, again with a fallback.
cy.request( baseRestUrl + '/api/authn/status' ) let baseDomain = FALLBACK_TEST_REST_DOMAIN;
.then((response) => { if (!config.rest.host) {
// We should receive a CSRF token returned in a response header console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
expect(response.headers).to.have.property('dspace-xsrf-token'); } else {
const csrfToken = response.headers['dspace-xsrf-token']; baseDomain = config.rest.host;
}
// Now, send login POST request including that CSRF token // Create a fake CSRF Token. Set it in the required server-side cookie
cy.request({ const csrfToken = 'fakeLoginCSRFToken';
method: 'POST', cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
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. // Now, send login POST request including that CSRF token
const authheader = resp.headers.authorization as string; cy.request({
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); method: 'POST',
url: baseRestUrl + '/api/authn/login',
headers: { [XSRF_REQUEST_HEADER]: 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');
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie // Initialize our AuthTokenInfo object from the authorization header.
// This ensures the UI will recognize we are logged in on next "visit()" const authheader = resp.headers.authorization as string;
cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); 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));
}); });
// Remove cookie with fake CSRF token, as it's no longer needed
cy.clearCookie(DSPACE_XSRF_COOKIE);
}); });
} }
// Add as a Cypress command (i.e. assign to 'cy.login') // Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login); Cypress.Commands.add('login', login);
/** /**
* Login user via displayed login form * Login user via displayed login form
* @param email email to login as * @param email email to login as
* @param password password to login as * @param password password to login as
*/ */
function loginViaForm(email: string, password: string): void { function loginViaForm(email: string, password: string): void {
// Enter email // Enter email
cy.get('ds-log-in [data-test="email"]').type(email); cy.get('ds-log-in [data-test="email"]').type(email);
// Enter password // Enter password
@@ -107,4 +126,69 @@ Cypress.Commands.add('login', login);
cy.get('ds-log-in [data-test="login-button"]').click(); cy.get('ds-log-in [data-test="login-button"]').click();
} }
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm') // Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
Cypress.Commands.add('loginViaForm', loginViaForm); Cypress.Commands.add('loginViaForm', loginViaForm);
/**
* Generate statistic view event for given object. Useful for testing statistics pages with
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
* generate multiple hits.
*
* NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend
* (as it is in our docker-compose-ci.yml used in CI).
* Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers.
* @param uuid UUID of object
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/
function generateViewEvent(uuid: string, dsoType: 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 {
baseRestUrl = config.rest.baseUrl;
}
// Now find domain of our REST API, again with a fallback.
let baseDomain = FALLBACK_TEST_REST_DOMAIN;
if (!config.rest.host) {
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
} else {
baseDomain = config.rest.host;
}
// Create a fake CSRF Token. Set it in the required server-side cookie
const csrfToken = 'fakeGenerateViewEventCSRFToken';
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
// Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
cy.request({
method: 'POST',
url: baseRestUrl + '/api/statistics/viewevents',
headers: {
[XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot"
'X-Forwarded-For': '1.1.1.1',
},
//form: true, // indicates the body should be form urlencoded
body: { targetId: uuid, targetType: dsoType },
}).then((resp) => {
// We expect a 201 (which means statistics event was created)
expect(resp.status).to.eq(201);
});
// Remove cookie with fake CSRF token, as it's no longer needed
cy.clearCookie(DSPACE_XSRF_COOKIE);
});
}
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
Cypress.Commands.add('generateViewEvent', generateViewEvent);

View File

@@ -30,11 +30,11 @@ beforeEach(() => {
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. // 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. // 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/ // Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
afterEach(() => { /*afterEach(() => {
cy.window().then((win) => { cy.window().then((win) => {
win.location.href = 'about:blank'; win.location.href = 'about:blank';
}); });
}); });*/
// Global constants used in tests // Global constants used in tests
@@ -43,10 +43,6 @@ afterEach(() => {
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment) // (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 // 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_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
@@ -61,3 +57,10 @@ export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLE
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144'; 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 = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
// USEFUL REGEX for testing
// Match any string that contains at least one non-space character
// Can be used with "contains()" to determine if an element has a non-empty text value
export const REGEX_MATCH_NON_EMPTY_TEXT = /^(?!\s*$).+/;

View File

@@ -6,21 +6,45 @@
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. 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 ## Overview
The scripts in this directory can be used to start the DSpace User Interface (frontend) in Docker.
Optionally, the backend (REST API) might also be started in Docker.
For additional options/settings in starting the backend (REST API) in Docker, see the Docker Compose
documentation for the backend: https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md
## Root directory
The root directory of this project contains all the Dockerfiles which may be referenced by
the Docker compose scripts in this 'docker' folder.
### Dockerfile
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' 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:latest .
``` ```
This image is built *automatically* after each commit is made to the `main` branch. 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. Admins to our DockerHub repo can manually publish with the following command.
``` ```
docker push dspace/dspace-angular:dspace-7_x docker push dspace/dspace-angular:latest
``` ```
## docker directory ### Dockerfile.dist
The `Dockerfile.dist` is used to generate a *production* build and runtime environment.
```bash
# build the latest image
docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
```
A default/demo version of this image is built *automatically*.
## 'docker' directory
- docker-compose.yml - 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. - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
- docker-compose-rest.yml - docker-compose-rest.yml
@@ -45,23 +69,47 @@ 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
This command provides a quick way to start both the frontend & backend from this single codebase
``` ```
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
``` ```
Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section.
## Run DSpace REST and DSpace Angular from local branches. ## Run DSpace REST and DSpace Angular from local branches.
This section assumes that you have clones *both* the 'DSpace/DSpace' and 'DSpace/dspace-angular' GitHub
repositories. When both are available locally, you can spin up both in Docker and have them work together.
_The system will be started in 2 steps. Each step shares the same docker network._ _The system will be started in 2 steps. Each step shares the same docker network._
From DSpace/DSpace (build as needed) From 'DSpace/DSpace' clone (build first as needed):
``` ```
docker-compose -p d7 up -d docker-compose -p d7 up -d
``` ```
From DSpace/DSpace-angular NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md).
From 'DSpace/dspace-angular' clone (build first as needed)
``` ```
docker-compose -p d7 -f docker/docker-compose.yml up -d docker-compose -p d7 -f docker/docker-compose.yml up -d
``` ```
At this point, you should be able to access the UI from http://localhost:4000,
and the backend at http://localhost:8080/server/
## Run DSpace Angular dist build with DSpace Demo site backend
This allows you to run the Angular UI in *production* mode, pointing it at the demo backend
(https://api7.dspace.org/server/).
```
docker-compose -f docker/docker-compose-dist.yml pull
docker-compose -f docker/docker-compose-dist.yml build
docker-compose -p d7 -f docker/docker-compose-dist.yml up -d
```
## Ingest test data from AIPDIR ## Ingest test data from AIPDIR
Create an administrator Create an administrator
@@ -87,9 +135,10 @@ 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 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). ## End to end testing of the REST API (runs in GitHub Actions CI).
_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._ _In this instance, only the REST api runs in Docker using the Entities dataset. GitHub Actions will perform CI testing of Angular using Node to drive the tests. See `.github/workflows/build.yml` for more details._
This command is only really useful for testing our Continuous Integration process.
``` ```
docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d
``` ```

View File

@@ -16,7 +16,7 @@ version: "3.7"
services: services:
dspace-cli: dspace-cli:
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
container_name: dspace-cli container_name: dspace-cli
environment: environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.

View File

@@ -30,9 +30,12 @@ services:
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
solr__P__server: http://dspacesolr:8983/solr solr__P__server: http://dspacesolr:8983/solr
# Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit.
# This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
solr__D__statistics__P__autoCommit: 'false'
depends_on: depends_on:
- dspacedb - dspacedb
image: dspace/dspace:dspace-7_x-test image: dspace/dspace:latest-test
networks: networks:
dspacenet: dspacenet:
ports: ports:

View File

@@ -0,0 +1,40 @@
#
# The contents of this file are subject to the license and copyright
# detailed in the LICENSE and NOTICE files at the root of the source
# tree and available online at
#
# http://www.dspace.org/license/
#
# Docker Compose for running the DSpace Angular UI dist build
# for previewing with the DSpace Demo site backend
version: '3.7'
networks:
dspacenet:
services:
dspace-angular:
container_name: dspace-angular
environment:
DSPACE_UI_SSL: 'false'
DSPACE_UI_HOST: dspace-angular
DSPACE_UI_PORT: '4000'
DSPACE_UI_NAMESPACE: /
# NOTE: When running the UI in production mode (which the -dist image does),
# these DSPACE_REST_* variables MUST point at a public, HTTPS URL.
# This is because Server Side Rendering (SSR) currently requires a public URL,
# see this bug: https://github.com/DSpace/dspace-angular/issues/1485
DSPACE_REST_SSL: 'true'
DSPACE_REST_HOST: api7.dspace.org
DSPACE_REST_PORT: 443
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist
build:
context: ..
dockerfile: Dockerfile.dist
networks:
dspacenet:
ports:
- published: 4000
target: 4000
stdin_open: true
tty: true

View File

@@ -39,7 +39,7 @@ services:
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0' proxies__P__trusted__P__ipranges: '172.23.0'
image: dspace/dspace:dspace-7_x-test image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on: depends_on:
- dspacedb - dspacedb
networks: networks:
@@ -82,8 +82,7 @@ services:
# DSpace Solr container # DSpace Solr container
dspacesolr: dspacesolr:
container_name: dspacesolr container_name: dspacesolr
# Uses official Solr image at https://hub.docker.com/_/solr/ image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
image: solr:8.11-slim
# Needs main 'dspace' container to start first to guarantee access to solr_configs # Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on: depends_on:
- dspace - dspace
@@ -96,28 +95,26 @@ services:
tty: true tty: true
working_dir: /var/solr/data working_dir: /var/solr/data
volumes: volumes:
# Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder)
# This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume
- solr_configs:/opt/solr/server/solr/configsets/dspace
# Keep Solr data directory between reboots # Keep Solr data directory between reboots
- solr_data:/var/solr/data - solr_data:/var/solr/data
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr # Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op # * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
# * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core # * Second, copy configsets to this core:
# to the latest configs. If it's a newly created core, this is a no-op. # Updates to Solr configs require the container to be rebuilt/restarted:
# `docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr`
entrypoint: entrypoint:
- /bin/bash - /bin/bash
- '-c' - '-c'
- | - |
init-var-solr init-var-solr
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority precreate-core authority /opt/solr/server/solr/configsets/authority
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority cp -r /opt/solr/server/solr/configsets/authority/* authority
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai precreate-core oai /opt/solr/server/solr/configsets/oai
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai cp -r /opt/solr/server/solr/configsets/oai/* oai
precreate-core search /opt/solr/server/solr/configsets/dspace/search precreate-core search /opt/solr/server/solr/configsets/search
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search cp -r /opt/solr/server/solr/configsets/search/* search
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics precreate-core statistics /opt/solr/server/solr/configsets/statistics
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics
exec solr -f exec solr -f
volumes: volumes:
assetstore: assetstore:

View File

@@ -24,7 +24,7 @@ services:
DSPACE_REST_HOST: localhost DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: /server DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:dspace-7_x image: dspace/dspace-angular:${DSPACE_VER:-latest}
build: build:
context: .. context: ..
dockerfile: Dockerfile dockerfile: Dockerfile

11
docker/dspace-ui.json Normal file
View File

@@ -0,0 +1,11 @@
{
"apps": [
{
"name": "dspace-ui",
"cwd": "/app",
"script": "dist/server/main.js",
"instances": "max",
"exec_mode": "cluster"
}
]
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "dspace-angular", "name": "dspace-angular",
"version": "7.5.0-next", "version": "8.0.0-next",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"config:watch": "nodemon", "config:watch": "nodemon",
@@ -17,9 +17,9 @@
"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",
"test": "ng test --sourceMap=true --watch=false --configuration test", "test": "ng test --source-map=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
"test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint", "lint": "ng lint",
"lint-fix": "ng lint --fix=true", "lint-fix": "ng lint --fix=true",
"e2e": "ng e2e", "e2e": "ng e2e",
@@ -55,131 +55,136 @@
"ts-node": "10.2.1" "ts-node": "10.2.1"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "~13.3.12", "@angular/animations": "^15.2.8",
"@angular/cdk": "^13.2.6", "@angular/cdk": "^15.2.8",
"@angular/common": "~13.3.12", "@angular/common": "^15.2.8",
"@angular/compiler": "~13.3.12", "@angular/compiler": "^15.2.8",
"@angular/core": "~13.3.12", "@angular/core": "^15.2.8",
"@angular/forms": "~13.3.12", "@angular/forms": "^15.2.8",
"@angular/localize": "13.3.12", "@angular/localize": "15.2.8",
"@angular/platform-browser": "~13.3.12", "@angular/platform-browser": "^15.2.8",
"@angular/platform-browser-dynamic": "~13.3.12", "@angular/platform-browser-dynamic": "^15.2.8",
"@angular/platform-server": "~13.3.12", "@angular/platform-server": "^15.2.8",
"@angular/router": "~13.3.12", "@angular/router": "^15.2.8",
"@babel/runtime": "7.17.2", "@babel/runtime": "7.21.0",
"@kolkov/ngx-gallery": "^2.0.1", "@kolkov/ngx-gallery": "^2.0.1",
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1", "@material-ui/icons": "^4.11.3",
"@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^15.0.0", "@ng-dynamic-forms/core": "^15.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0", "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
"@ngrx/effects": "^13.0.2", "@ngrx/effects": "^15.4.0",
"@ngrx/router-store": "^13.0.2", "@ngrx/router-store": "^15.4.0",
"@ngrx/store": "^13.0.2", "@ngrx/store": "^15.4.0",
"@nguniversal/express-engine": "^13.0.2", "@nguniversal/express-engine": "^15.2.1",
"@ngx-translate/core": "^13.0.0", "@ngx-translate/core": "^14.0.0",
"@nicky-lenaers/ngx-scroll-to": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4", "@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0", "angular-idle-preload": "3.0.0",
"angulartics2": "^12.0.0", "angulartics2": "^12.2.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"bootstrap": "^4.6.1", "bootstrap": "^4.6.1",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.8.0", "cli-progress": "^3.12.0",
"colors": "^1.4.0", "colors": "^1.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cookie-parser": "1.4.5", "cookie-parser": "1.4.6",
"core-js": "^3.7.0", "core-js": "^3.30.1",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7", "date-fns-tz": "^1.3.7",
"deepmerge": "^4.2.2", "deepmerge": "^4.3.1",
"express": "^4.17.1", "ejs": "^3.1.9",
"express": "^4.18.2",
"express-rate-limit": "^5.1.3", "express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.0.0-1", "fast-json-patch": "^3.1.1",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^1.0.5",
"isbot": "^3.6.10",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json5": "^2.2.2", "json5": "^2.2.3",
"jsonschema": "1.4.0", "jsonschema": "1.4.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"klaro": "^0.7.18", "klaro": "^0.7.18",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-mathjax3": "^4.3.1", "markdown-it-mathjax3": "^4.3.2",
"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",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ng-mocks": "^13.1.1", "ng-mocks": "^14.10.0",
"ng2-file-upload": "1.4.0", "ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.3", "ng2-nouislider": "^1.8.3",
"ngx-infinite-scroll": "^10.0.1", "ngx-infinite-scroll": "^15.0.0",
"ngx-pagination": "5.0.0", "ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0", "ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^13.0.2", "ngx-ui-switch": "^14.0.3",
"nouislider": "^14.6.3", "nouislider": "^14.6.3",
"pem": "1.14.4", "pem": "1.14.7",
"prop-types": "^15.7.2", "prop-types": "^15.8.1",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.5.5", "rxjs": "^7.8.0",
"sanitize-html": "^2.7.2", "sanitize-html": "^2.10.0",
"sortablejs": "1.13.0", "sortablejs": "1.15.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webfontloader": "1.6.28", "webfontloader": "1.6.28",
"zone.js": "~0.11.5" "zone.js": "~0.11.5"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "~13.1.0", "@angular-builders/custom-webpack": "~15.0.0",
"@angular-devkit/build-angular": "~13.3.10", "@angular-devkit/build-angular": "^15.2.6",
"@angular-eslint/builder": "13.1.0", "@angular-eslint/builder": "15.2.1",
"@angular-eslint/eslint-plugin": "13.1.0", "@angular-eslint/eslint-plugin": "15.2.1",
"@angular-eslint/eslint-plugin-template": "13.1.0", "@angular-eslint/eslint-plugin-template": "15.2.1",
"@angular-eslint/schematics": "13.1.0", "@angular-eslint/schematics": "15.2.1",
"@angular-eslint/template-parser": "13.1.0", "@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "~13.3.10", "@angular/cli": "^15.2.6",
"@angular/compiler-cli": "~13.3.12", "@angular/compiler-cli": "^15.2.8",
"@angular/language-service": "~13.3.12", "@angular/language-service": "^15.2.8",
"@cypress/schematic": "^1.5.0", "@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.2.1", "@fortawesome/fontawesome-free": "^6.4.0",
"@ngrx/store-devtools": "^13.0.2", "@ngrx/store-devtools": "^15.4.0",
"@ngtools/webpack": "^13.2.6", "@ngtools/webpack": "^15.2.6",
"@nguniversal/builders": "^13.1.1", "@nguniversal/builders": "^15.2.1",
"@types/deep-freeze": "0.1.2", "@types/deep-freeze": "0.1.2",
"@types/express": "^4.17.9", "@types/ejs": "^3.1.2",
"@types/express": "^4.17.17",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.165", "@types/lodash": "^4.14.194",
"@types/node": "^14.14.9", "@types/node": "^14.14.9",
"@types/sanitize-html": "^2.6.2", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "5.11.0", "@typescript-eslint/parser": "^5.59.1",
"axe-core": "^4.4.3", "axe-core": "^4.7.0",
"compression-webpack-plugin": "^9.2.0", "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",
"cypress": "9.7.0", "cypress": "12.10.0",
"cypress-axe": "^0.14.0", "cypress-axe": "^1.4.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"eslint": "^8.2.0", "eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.3.2", "eslint-plugin-deprecation": "^1.4.1",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.5", "express-static-gzip": "^2.1.7",
"jasmine-core": "^3.8.0", "jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2", "jasmine-marbles": "0.9.2",
"karma": "^6.3.14", "karma": "^6.4.2",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.3",
"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",
"ngx-mask": "^13.1.7", "ngx-mask": "^13.1.7",
"nodemon": "^2.0.20", "nodemon": "^2.0.22",
"postcss": "^8.1", "postcss": "^8.4",
"postcss-apply": "0.12.0", "postcss-apply": "0.12.0",
"postcss-import": "^14.0.0", "postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3", "postcss-loader": "^4.0.3",
@@ -189,14 +194,14 @@
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs-spy": "^8.0.2", "rxjs-spy": "^8.0.2",
"sass": "~1.33.0", "sass": "~1.62.0",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"sass-resources-loader": "^2.1.1", "sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"typescript": "~4.5.5", "typescript": "~4.8.4",
"webpack": "^5.69.1", "webpack": "5.76.1",
"webpack-bundle-analyzer": "^4.4.0", "webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^4.2.0", "webpack-cli": "^4.2.0",
"webpack-dev-server": "^4.5.0" "webpack-dev-server": "^4.13.3"
} }
} }

View File

@@ -1,13 +0,0 @@
const path = require('path');
const child_process = require('child_process');
const heapSize = 4096;
const webpackPath = path.join('node_modules', 'webpack', 'bin', 'webpack.js');
const params = [
'--max_old_space_size=' + heapSize,
webpackPath,
...process.argv.slice(2)
];
child_process.spawn('node', params, { stdio:'inherit' });

370
server.ts
View File

@@ -22,19 +22,20 @@ import 'rxjs';
/* eslint-disable import/no-namespace */ /* eslint-disable import/no-namespace */
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as express from 'express'; import * as express from 'express';
import * as ejs from 'ejs';
import * as compression from 'compression'; import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip'; import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */ /* eslint-enable import/no-namespace */
import axios from 'axios'; import axios from 'axios';
import LRU from 'lru-cache';
import isbot from 'isbot';
import { createCertificate } from 'pem'; import { createCertificate } from 'pem';
import { createServer } from 'https'; import { createServer } from 'https';
import { json } from 'body-parser'; import { json } from 'body-parser';
import { existsSync, readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { ngExpressEngine } from '@nguniversal/express-engine'; import { ngExpressEngine } from '@nguniversal/express-engine';
@@ -52,6 +53,8 @@ import { buildAppConfig } from './src/config/config.server';
import { APP_CONFIG, AppConfig } 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'; import { logStartupMessage } from './startup-message';
import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model';
/* /*
* Set path for the browser application's dist folder * Set path for the browser application's dist folder
@@ -60,12 +63,18 @@ const DIST_FOLDER = join(process.cwd(), 'dist/browser');
// Set path fir IIIF viewer. // Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif'); const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index'; const indexHtml = join(DIST_FOLDER, 'index.html');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
// cache of SSR pages for known bots, only enabled in production mode
let botCache: LRU<string, any>;
// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
let anonymousCache: LRU<string, any>;
// extend environment with app config for server // extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig); extendEnvironmentWithAppConfig(environment, appConfig);
@@ -86,10 +95,12 @@ export function app() {
/* /*
* 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
* - Initialize caching of SSR rendered pages (if enabled in config.yml)
* - Enable compression for SSR reponses. 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();
initCache();
server.use(compression({ server.use(compression({
// only compress responses we've marked as SSR // only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin // otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
@@ -105,13 +116,13 @@ export function app() {
/* /*
* Add cookie parser middleware * Add cookie parser middleware
* See [morgan](https://github.com/expressjs/cookie-parser) * See [cookie-parser](https://github.com/expressjs/cookie-parser)
*/ */
server.use(cookieParser()); server.use(cookieParser());
/* /*
* Add parser for request bodies * Add JSON parser for request bodies
* See [morgan](https://github.com/expressjs/body-parser) * See [body-parser](https://github.com/expressjs/body-parser)
*/ */
server.use(json()); server.use(json());
@@ -136,10 +147,23 @@ export function app() {
})(_, (options as any), callback) })(_, (options as any), callback)
); );
server.engine('ejs', ejs.renderFile);
/* /*
* Register the view engines for html and ejs * Register the view engines for html and ejs
*/ */
server.set('view engine', 'html'); server.set('view engine', 'html');
server.set('view engine', 'ejs');
/**
* Serve the robots.txt ejs template, filling in the origin variable
*/
server.get('/robots.txt', (req, res) => {
res.setHeader('content-type', 'text/plain');
res.render('assets/robots.txt.ejs', {
'origin': req.protocol + '://' + req.headers.host
});
});
/* /*
* Set views folder path to directory where template files are stored * Set views folder path to directory where template files are stored
@@ -155,6 +179,15 @@ export function app() {
changeOrigin: true changeOrigin: true
})); }));
/**
* Proxy the linksets
*/
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
}));
/** /**
* Checks if the rateLimiter property is present * Checks if the rateLimiter property is present
* When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled.
@@ -172,7 +205,7 @@ 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) * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
*/ */
router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
index: false, index: false,
enableBrotli: true, enableBrotli: true,
orderPreference: ['br', 'gzip'], orderPreference: ['br', 'gzip'],
@@ -188,8 +221,11 @@ export function app() {
*/ */
server.get('/app/health', healthCheck); server.get('/app/health', healthCheck);
// Register the ngApp callback function to handle incoming requests /**
router.get('*', ngApp); * Default sending all incoming requests to ngApp() function, after first checking for a cached
* copy of the page (see cacheCheck())
*/
router.get('*', cacheCheck, ngApp);
server.use(environment.ui.nameSpace, router); server.use(environment.ui.nameSpace, router);
@@ -201,60 +237,280 @@ export function app() {
*/ */
function ngApp(req, res) { function ngApp(req, res) {
if (environment.universal.preboot) { if (environment.universal.preboot) {
res.render(indexHtml, { // Render the page to user via SSR (server side rendering)
req, serverSideRender(req, res);
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR
res.send(data);
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in SSR, serving for direct CSR.');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
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 client-side rendering (CSR)');
res.render(indexHtml, { clientSideRender(req, res);
req, }
providers: [{ }
provide: APP_BASE_HREF,
useValue: req.baseUrl /**
}] * Render page content on server side using Angular SSR. By default this page content is
* returned to the user.
* @param req current request
* @param res current response
* @param sendToUser if true (default), send the rendered content to the user.
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
*/
function serverSideRender(req, res, sendToUser: boolean = true) {
// Render the page via SSR (server side rendering)
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
// save server side rendered page to cache (if any are enabled)
saveToCache(req, data);
if (sendToUser) {
res.locals.ssr = true; // mark response as SSR (enables text compression)
// send rendered page to user
res.send(data);
}
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in server-side rendering (SSR)');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
if (sendToUser) {
console.warn('Falling back to serving direct client-side rendering (CSR).');
clientSideRender(req, res);
}
}
});
}
/**
* Send back response to user to trigger direct client-side rendering (CSR)
* @param req current request
* @param res current response
*/
function clientSideRender(req, res) {
res.sendFile(indexHtml);
}
/*
* Adds a Cache-Control HTTP header to the response.
* The cache control value can be configured in the config.*.yml file
* Defaults to max-age=604,800 seconds (1 week)
*/
function addCacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
next();
}
/*
* Initialize server-side caching of pages rendered via SSR.
*/
function initCache() {
if (botCacheEnabled()) {
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
// See https://www.npmjs.com/package/lru-cache
// When enabled, each page defaults to expiring after 1 day
botCache = new LRU( {
max: environment.cache.serverSide.botCache.max,
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
});
}
if (anonymousCacheEnabled()) {
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
// may expire pages more frequently.
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
anonymousCache = new LRU( {
max: environment.cache.serverSide.anonymousCache.max,
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
}); });
} }
} }
/* /**
* Adds a cache control header to the response * Return whether bot-specific server side caching is enabled in configuration.
* The cache control value can be configured in the environments file and defaults to max-age=60
*/ */
function cacheControl(req, res, next) { function botCacheEnabled(): boolean {
// instruct browser to revalidate // Caching is only enabled if SSR is enabled AND
res.header('Cache-Control', environment.cache.control || 'max-age=60'); // "max" pages to cache is greater than zero
next(); return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
}
/**
* Return whether anonymous user server side caching is enabled in configuration.
*/
function anonymousCacheEnabled(): boolean {
// Caching is only enabled if SSR is enabled AND
// "max" pages to cache is greater than zero
return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
}
/**
* Check if the currently requested page is in our server-side, in-memory cache.
* Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
*/
function cacheCheck(req, res, next) {
// Cached copy of page (if found)
let cachedCopy;
// If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
if (botCacheEnabled() && isbot(req.get('user-agent'))) {
cachedCopy = checkCacheForRequest('bot', botCache, req, res);
} else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
}
// If cached copy exists, return it to the user.
if (cachedCopy && cachedCopy.page) {
if (cachedCopy.headers) {
Object.keys(cachedCopy.headers).forEach((header) => {
if (cachedCopy.headers[header]) {
if (environment.cache.serverSide.debug) {
console.log(`Restore cached ${header} header`);
}
res.setHeader(header, cachedCopy.headers[header]);
}
});
}
res.locals.ssr = true; // mark response as SSR-generated (enables text compression)
res.send(cachedCopy.page);
// Tell Express to skip all other handlers for this path
// This ensures we don't try to re-render the page since we've already returned the cached copy
next('router');
} else {
// If nothing found in cache, just continue with next handler
// (This should send the request on to the handler that rerenders the page via SSR
next();
}
}
/**
* Checks if the current request (i.e. page) is found in the given cache. If it is found,
* the cached copy is returned. When found, this method also triggers a re-render via
* SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy).
* @param cacheName name of cache (just useful for debug logging)
* @param cache LRU cache to check
* @param req current request to look for in the cache
* @param res current response
* @returns cached copy (if found) or undefined (if not found)
*/
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res): any {
// Get the cache key for this request
const key = getCacheKey(req);
// Check if this page is in our cache
let cachedCopy = cache.get(key);
if (cachedCopy) {
if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
// Check if cached copy is expired (If expired, the key will now be gone from cache)
// NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value.
if (!cache.has(key)) {
if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); }
// Update cached copy by rerendering server-side
// NOTE: In this scenario the currently cached copy will be returned to the current user.
// This re-render is peformed behind the scenes to update cached copy for next user.
serverSideRender(req, res, false);
}
} else {
if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
}
// return page from cache
return cachedCopy;
}
/**
* Create a cache key from the current request.
* The cache key is the URL path (NOTE: this key will also include any querystring params).
* E.g. "/home" or "/search?query=test"
* @param req current request
* @returns cache key to use for this page
*/
function getCacheKey(req): string {
// NOTE: this will return the URL path *without* any baseUrl
return req.url;
}
/**
* Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop
* If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired).
* (This minimizes the number of times we need to run SSR on the same page.)
* @param req current page request
* @param page page data to save to cache
*/
function saveToCache(req, page: any) {
// Only cache if no one is currently authenticated. This means ONLY public pages can be cached.
// NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation,
// the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info.
if (!isUserAuthenticated(req)) {
const key = getCacheKey(req);
// Avoid caching "/reload/[random]" paths (these are hard refreshes after logout)
if (key.startsWith('/reload')) { return; }
// Avoid caching not successful responses (status code different from 2XX status)
if (hasNotSucceeded(req.res.statusCode)) { return; }
// Retrieve response headers to save, if any
const headers = retrieveHeaders(req.res);
// If bot cache is enabled, save it to that cache if it doesn't exist or is expired
// (NOTE: has() will return false if page is expired in cache)
if (botCacheEnabled() && !botCache.has(key)) {
botCache.set(key, { page, headers });
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); }
}
// If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired
if (anonymousCacheEnabled() && !anonymousCache.has(key)) {
anonymousCache.set(key, { page, headers });
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); }
}
}
}
/**
* Check if status code is different from 2XX
* @param statusCode
*/
function hasNotSucceeded(statusCode) {
const rgx = new RegExp(/^20+/);
return !rgx.test(statusCode)
}
function retrieveHeaders(response) {
const headers = Object.create({});
if (Array.isArray(environment.cache.serverSide.headers) && environment.cache.serverSide.headers.length > 0) {
environment.cache.serverSide.headers.forEach((header) => {
if (response.hasHeader(header)) {
if (environment.cache.serverSide.debug) {
console.log(`Save ${header} header to cache`);
}
headers[header] = response.getHeader(header);
}
});
}
return headers;
}
/**
* Whether a user is authenticated or not
*/
function isUserAuthenticated(req): boolean {
// Check whether our DSpace authentication Cookie exists or not
return req.cookies[TOKENITEM];
} }
/* /*

View File

@@ -6,8 +6,13 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
import { GROUP_EDIT_PATH } from './access-control-routing-paths'; import { GROUP_EDIT_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard'; import { GroupPageGuard } from './group-registry/group-page.guard';
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import {
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; GroupAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import {
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -47,7 +52,16 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu
}, },
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
canActivate: [GroupPageGuard] canActivate: [GroupPageGuard]
} },
{
path: 'bulk-access',
component: BulkAccessComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: [SiteAdministratorGuard]
},
]) ])
] ]
}) })

View File

@@ -12,6 +12,12 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
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 { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { AbstractControl } from '@angular/forms'; import { AbstractControl } from '@angular/forms';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
import { SearchModule } from '../shared/search/search.module';
import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';
/** /**
* Condition for displaying error messages on email form field * Condition for displaying error messages on email form field
@@ -27,7 +33,13 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
SharedModule, SharedModule,
RouterModule, RouterModule,
AccessControlRoutingModule, AccessControlRoutingModule,
FormModule FormModule,
NgbAccordionModule,
SearchModule,
AccessControlFormModule,
],
exports: [
MembersListComponent,
], ],
declarations: [ declarations: [
EPeopleRegistryComponent, EPeopleRegistryComponent,
@@ -35,7 +47,10 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
GroupsRegistryComponent, GroupsRegistryComponent,
GroupFormComponent, GroupFormComponent,
SubgroupsListComponent, SubgroupsListComponent,
MembersListComponent MembersListComponent,
BulkAccessComponent,
BulkAccessBrowseComponent,
BulkAccessSettingsComponent,
], ],
providers: [ providers: [
{ {

View File

@@ -0,0 +1,67 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
<ngb-panel [id]="'browse'">
<ng-template ngbPanelHeader>
<div class="w-100 d-flex justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')"
data-test="browse">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
[attr.aria-expanded]="!acc.isExpanded('browse')"
aria-controls="collapsePanels">
{{ 'admin.access-control.bulk-access-browse.header' | translate }}
</button>
<div class="text-right d-flex">
<div class="ml-3 d-inline-block">
<span *ngIf="acc.isExpanded('browse')" class="fas fa-chevron-up fa-fw"></span>
<span *ngIf="!acc.isExpanded('browse')" class="fas fa-chevron-down fa-fw"></span>
</div>
</div>
</div>
</ng-template>
<ng-template ngbPanelContent>
<ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills">
<li [ngbNavItem]="'search'">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent>
<div class="mx-n3">
<ds-themed-search [configuration]="'administrativeBulkAccess'"
[selectable]="true"
[selectionConfig]="{ repeatable: true, listId: listId }"
[showThumbnails]="false"></ds-themed-search>
</div>
</ng-template>
</li>
<li [ngbNavItem]="'selected'">
<a ngbNavLink>
{{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }}
</a>
<ng-template ngbNavContent>
<ds-pagination
[paginationOptions]="(paginationOptions$ | async)"
[pageInfoState]="(objectsSelected$|async)?.payload.pageInfo"
[collectionSize]="(objectsSelected$|async)?.payload?.totalElements"
[objects]="(objectsSelected$|async)"
[showPaginator]="false"
(prev)="pagePrev()"
(next)="pageNext()">
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4">
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last '
class="mt-4 mb-4 d-flex"
[attr.data-test]="'list-object' | dsBrowserOnly">
<ds-selectable-list-item-control [index]="i"
[object]="object"
[selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control>
<ds-listable-object-component-loader [listID]="listId"
[index]="i"
[object]="object"
[showThumbnails]="false"
[viewMode]="'list'"></ds-listable-object-component-loader>
</li>
</ul>
</ds-pagination>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-5"></div>
</ng-template>
</ngb-panel>
</ngb-accordion>

View File

@@ -0,0 +1,82 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec';
import { PageInfo } from '../../../core/shared/page-info.model';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
describe('BulkAccessBrowseComponent', () => {
let component: BulkAccessBrowseComponent;
let fixture: ComponentFixture<BulkAccessBrowseComponent>;
const listID1 = 'id1';
const value1 = 'Selected object';
const value2 = 'Another selected object';
const selected1 = new SelectableObject(value1);
const selected2 = new SelectableObject(value2);
const testSelection = { id: listID1, selection: [selected1, selected2] } ;
const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
NgbAccordionModule,
NgbNavModule,
TranslateModule.forRoot()
],
declarations: [BulkAccessBrowseComponent],
providers: [ { provide: SelectableListService, useValue: selectableListService }, ],
schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BulkAccessBrowseComponent);
component = fixture.componentInstance;
(component as any).selectableListService.getSelectableList.and.returnValue(of(testSelection));
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
component = null;
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should have an initial active nav id of "search"', () => {
expect(component.activateId).toEqual('search');
});
it('should have an initial pagination options object with default values', () => {
expect(component.paginationOptions$.getValue().id).toEqual('bas');
expect(component.paginationOptions$.getValue().pageSize).toEqual(5);
expect(component.paginationOptions$.getValue().currentPage).toEqual(1);
});
it('should have an initial remote data with a paginated list as value', () => {
const list = buildPaginatedList(new PageInfo({
'elementsPerPage': 5,
'totalElements': 2,
'totalPages': 1,
'currentPage': 1
}), [selected1, selected2]) ;
const rd = createSuccessfulRemoteDataObject(list);
expect(component.objectsSelected$.value).toEqual(rd);
});
});

View File

@@ -0,0 +1,119 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
import { RemoteData } from '../../../core/data/remote-data';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { hasValue } from '../../../shared/empty.util';
@Component({
selector: 'ds-bulk-access-browse',
templateUrl: 'bulk-access-browse.component.html',
styleUrls: ['./bulk-access-browse.component.scss'],
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
})
export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
/**
* The selection list id
*/
@Input() listId!: string;
/**
* The active nav id
*/
activateId = 'search';
/**
* The list of the objects already selected
*/
objectsSelected$: BehaviorSubject<RemoteData<PaginatedList<ListableObject>>> = new BehaviorSubject<RemoteData<PaginatedList<ListableObject>>>(null);
/**
* The pagination options object used for the list of selected elements
*/
paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), {
id: 'bas',
pageSize: 5,
currentPage: 1
}));
/**
* Array to track all subscriptions and unsubscribe them onDestroy
*/
private subs: Subscription[] = [];
constructor(private selectableListService: SelectableListService) {}
/**
* Subscribe to selectable list updates
*/
ngOnInit(): void {
this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(),
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list))
).subscribe(this.objectsSelected$)
);
}
pageNext() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage + 1
}));
}
pagePrev() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage - 1
}));
}
private calculatePageCount(pageSize, totalCount = 0) {
// we suppose that if we have 0 items we want 1 empty page
return totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize);
}
/**
* Generate The RemoteData object containing the list of the selected elements
* @param list
* @private
*/
private generatePaginatedListBySelectedElements(list: SelectableListState): RemoteData<PaginatedList<ListableObject>> {
const pageInfo = new PageInfo({
elementsPerPage: this.paginationOptions$.value.pageSize,
totalElements: list?.selection.length,
totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length),
currentPage: this.paginationOptions$.value.currentPage
});
if (pageInfo.currentPage > pageInfo.totalPages) {
pageInfo.currentPage = pageInfo.totalPages;
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: pageInfo.currentPage
}));
}
return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || []));
}
ngOnDestroy(): void {
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
this.selectableListService.deselectAll(this.listId);
}
}

View File

@@ -0,0 +1,19 @@
<div class="container">
<ds-bulk-access-browse [listId]="listId"></ds-bulk-access-browse>
<div class="clearfix mb-3"></div>
<ds-bulk-access-settings #dsBulkSettings ></ds-bulk-access-settings>
<hr>
<div class="d-flex justify-content-end">
<button class="btn btn-outline-primary mr-3" (click)="reset()">
{{ 'access-control-cancel' | translate }}
</button>
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
{{ 'access-control-execute' | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,158 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BulkAccessComponent } from './bulk-access.component';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { Process } from '../../process-page/processes/process.model';
import { RouterTestingModule } from '@angular/router/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
describe('BulkAccessComponent', () => {
let component: BulkAccessComponent;
let fixture: ComponentFixture<BulkAccessComponent>;
let bulkAccessControlService: any;
let selectableListService: any;
const selectableListServiceMock = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']);
const bulkAccessControlServiceMock = jasmine.createSpyObj('bulkAccessControlService', ['createPayloadFile', 'executeScript']);
const mockFormState = {
'bitstream': [],
'item': [
{
'name': 'embargo',
'startDate': {
'year': 2026,
'month': 5,
'day': 31
},
'endDate': null
}
],
'state': {
'item': {
'toggleStatus': true,
'accessMode': 'replace'
},
'bitstream': {
'toggleStatus': false,
'accessMode': '',
'changesLimit': '',
'selectedBitstreams': []
}
}
};
const mockFile = {
'uuids': [
'1234', '5678'
],
'file': { }
};
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getValue: jasmine.createSpy('getValue'),
reset: jasmine.createSpy('reset')
});
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
const selectableListState: SelectableListState = { id: 'test', selection };
const expectedIdList = ['1234', '5678'];
const selectableListStateEmpty: SelectableListState = { id: 'test', selection: [] };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule,
TranslateModule.forRoot()
],
declarations: [ BulkAccessComponent ],
providers: [
{ provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock },
{ provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: SelectableListService, useValue: selectableListServiceMock }
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BulkAccessComponent);
component = fixture.componentInstance;
bulkAccessControlService = TestBed.inject(BulkAccessControlService);
selectableListService = TestBed.inject(SelectableListService);
});
afterEach(() => {
fixture.destroy();
});
describe('when there are no elements selected', () => {
beforeEach(() => {
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty));
fixture.detectChanges();
component.settings = mockSettings;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should generate the id list by selected elements', () => {
expect(component.objectsSelected$.value).toEqual([]);
});
it('should disable the execute button when there are no objects selected', () => {
expect(component.canExport()).toBe(false);
});
});
describe('when there are elements selected', () => {
beforeEach(() => {
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
fixture.detectChanges();
component.settings = mockSettings;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should generate the id list by selected elements', () => {
expect(component.objectsSelected$.value).toEqual(expectedIdList);
});
it('should enable the execute button when there are objects selected', () => {
component.objectsSelected$.next(['1234']);
expect(component.canExport()).toBe(true);
});
it('should call the settings reset method when reset is called', () => {
component.reset();
expect(component.settings.reset).toHaveBeenCalled();
});
it('should call the bulkAccessControlService executeScript method when submit is called', () => {
(component.settings as any).getValue.and.returnValue(mockFormState);
bulkAccessControlService.createPayloadFile.and.returnValue(mockFile);
bulkAccessControlService.executeScript.and.returnValue(createSuccessfulRemoteDataObject$(new Process()));
component.objectsSelected$.next(['1234']);
component.submit();
expect(bulkAccessControlService.executeScript).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,94 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { BehaviorSubject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
@Component({
selector: 'ds-bulk-access',
templateUrl: './bulk-access.component.html',
styleUrls: ['./bulk-access.component.scss']
})
export class BulkAccessComponent implements OnInit {
/**
* The selection list id
*/
listId = 'bulk-access-list';
/**
* The list of the objects already selected
*/
objectsSelected$: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
/**
* Array to track all subscriptions and unsubscribe them onDestroy
*/
private subs: Subscription[] = [];
/**
* The SectionsDirective reference
*/
@ViewChild('dsBulkSettings') settings: BulkAccessSettingsComponent;
constructor(
private bulkAccessControlService: BulkAccessControlService,
private selectableListService: SelectableListService
) {
}
ngOnInit(): void {
this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(),
map((list: SelectableListState) => this.generateIdListBySelectedElements(list))
).subscribe(this.objectsSelected$)
);
}
canExport(): boolean {
return this.objectsSelected$.value?.length > 0;
}
/**
* Reset the form to its initial state
* This will also reset the state of the child components (bitstream and item access)
*/
reset(): void {
this.settings.reset();
}
/**
* Submit the form
* This will create a payload file and execute the script
*/
submit(): void {
const settings = this.settings.getValue();
const bitstreamAccess = settings.bitstream;
const itemAccess = settings.item;
const { file } = this.bulkAccessControlService.createPayloadFile({
bitstreamAccess,
itemAccess,
state: settings.state
});
this.bulkAccessControlService.executeScript(
this.objectsSelected$.value || [],
file
).subscribe();
}
/**
* Generate The RemoteData object containing the list of the selected elements
* @param list
* @private
*/
private generateIdListBySelectedElements(list: SelectableListState): string[] {
return list?.selection?.map((entry: any) => entry.indexableObject.uuid);
}
}

View File

@@ -0,0 +1,21 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'settings'">
<ngb-panel [id]="'settings'">
<ng-template ngbPanelHeader>
<div class="w-100 d-flex justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('settings')" data-test="settings">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()" [attr.aria-expanded]="!acc.isExpanded('browse')"
aria-controls="collapsePanels">
{{ 'admin.access-control.bulk-access-settings.header' | translate }}
</button>
<div class="text-right d-flex">
<div class="ml-3 d-inline-block">
<span *ngIf="acc.isExpanded('settings')" class="fas fa-chevron-up fa-fw"></span>
<span *ngIf="!acc.isExpanded('settings')" class="fas fa-chevron-down fa-fw"></span>
</div>
</div>
</div>
</ng-template>
<ng-template ngbPanelContent>
<ds-access-control-form-container #dsAccessControlForm [showSubmit]="false"></ds-access-control-form-container>
</ng-template>
</ngb-panel>
</ngb-accordion>

View File

@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { BulkAccessSettingsComponent } from './bulk-access-settings.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('BulkAccessSettingsComponent', () => {
let component: BulkAccessSettingsComponent;
let fixture: ComponentFixture<BulkAccessSettingsComponent>;
const mockFormState = {
'bitstream': [],
'item': [
{
'name': 'embargo',
'startDate': {
'year': 2026,
'month': 5,
'day': 31
},
'endDate': null
}
],
'state': {
'item': {
'toggleStatus': true,
'accessMode': 'replace'
},
'bitstream': {
'toggleStatus': false,
'accessMode': '',
'changesLimit': '',
'selectedBitstreams': []
}
}
};
const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getFormValue: jasmine.createSpy('getFormValue'),
reset: jasmine.createSpy('reset')
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NgbAccordionModule, TranslateModule.forRoot()],
declarations: [BulkAccessSettingsComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BulkAccessSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.controlForm = mockControl;
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should have a method to get the form value', () => {
expect(component.getValue).toBeDefined();
});
it('should have a method to reset the form', () => {
expect(component.reset).toBeDefined();
});
it('should return the correct form value', () => {
const expectedValue = mockFormState;
(component.controlForm as any).getFormValue.and.returnValue(mockFormState);
const actualValue = component.getValue();
// @ts-ignore
expect(actualValue).toEqual(expectedValue);
});
it('should call reset on the control form', () => {
component.reset();
expect(component.controlForm.reset).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import { Component, ViewChild } from '@angular/core';
import {
AccessControlFormContainerComponent
} from '../../../shared/access-control-form-container/access-control-form-container.component';
@Component({
selector: 'ds-bulk-access-settings',
templateUrl: 'bulk-access-settings.component.html',
styleUrls: ['./bulk-access-settings.component.scss'],
exportAs: 'dsBulkSettings'
})
export class BulkAccessSettingsComponent {
/**
* The SectionsDirective reference
*/
@ViewChild('dsAccessControlForm') controlForm: AccessControlFormContainerComponent<any>;
/**
* Will be used from a parent component to read the value of the form
*/
getValue() {
return this.controlForm.getFormValue();
}
/**
* Reset the form to its initial state
* This will also reset the state of the child components (bitstream and item access)
*/
reset() {
this.controlForm.reset();
}
}

View File

@@ -8,7 +8,7 @@
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
(click)="isEPersonFormShown = true"> (click)="isEPersonFormShown = true">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">{{labelPrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -30,7 +30,7 @@
<div class="flex-grow-1 mr-3 ml-3"> <div class="flex-grow-1 mr-3 ml-3">
<div class="form-group input-group"> <div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" attr.aria-label="{{labelPrefix + 'search.placeholder' | translate}}" class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)"> [placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
@@ -68,18 +68,18 @@
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page" <tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}"> [ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td> <td>{{epersonDto.eperson.id}}</td>
<td>{{epersonDto.eperson.name}}</td> <td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td> <td>{{epersonDto.eperson.email}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)" <button (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton" class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)" <button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton" class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
@@ -42,6 +42,7 @@ describe('EPeopleRegistryComponent', () => {
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
jasmine.getEnv().allowRespy(true);
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
activeEPerson: null, activeEPerson: null,
@@ -98,7 +99,7 @@ describe('EPeopleRegistryComponent', () => {
deleteEPerson(ePerson: EPerson): Observable<boolean> { deleteEPerson(ePerson: EPerson): Observable<boolean> {
this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => {
return (ePerson2.uuid !== ePerson.uuid); return (ePerson2.uuid !== ePerson.uuid);
}); });
return observableOf(true); return observableOf(true);
}, },
editEPerson(ePerson: EPerson) { editEPerson(ePerson: EPerson) {
@@ -260,17 +261,16 @@ describe('EPeopleRegistryComponent', () => {
describe('delete EPerson button when the isAuthorized returns false', () => { describe('delete EPerson button when the isAuthorized returns false', () => {
let ePeopleDeleteButton; let ePeopleDeleteButton;
beforeEach(() => { beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', { spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false));
isAuthorized: observableOf(false) component.initialisePage();
}); fixture.detectChanges();
}); });
it('should be disabled', () => { it('should be disabled', () => {
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
ePeopleDeleteButton.forEach((deleteButton) => { ePeopleDeleteButton.forEach((deleteButton: DebugElement) => {
expect(deleteButton.nativeElement.disabled).toBe(true); expect(deleteButton.nativeElement.disabled).toBe(true);
}); });
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { UntypedFormBuilder } 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 { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
@@ -21,6 +21,7 @@ import { RequestService } from '../../core/data/request.service';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
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 { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@Component({ @Component({
selector: 'ds-epeople-registry', selector: 'ds-epeople-registry',
@@ -89,11 +90,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private router: Router, private router: Router,
private modalService: NgbModal, private modalService: NgbModal,
private paginationService: PaginationService, private paginationService: PaginationService,
public requestService: RequestService) { public requestService: RequestService,
public dsoNameService: DSONameService,
) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata'; this.currentSearchScope = 'metadata';
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
@@ -121,7 +124,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.subs.push(this.ePeople$.pipe( this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => { switchMap((epeople: PaginatedList<EPerson>) => {
if (epeople.pageInfo.totalElements > 0) { if (epeople.pageInfo.totalElements > 0) {
return combineLatest(...epeople.page.map((eperson) => { return combineLatest([...epeople.page.map((eperson: EPerson) => {
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
map((authorized) => { map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -130,7 +133,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}) })
); );
})).pipe(map((dtos: EpersonDtoModel[]) => { })]).pipe(map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epeople.pageInfo, dtos); return buildPaginatedList(epeople.pageInfo, dtos);
})); }));
} else { } else {
@@ -237,7 +240,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
if (hasValue(ePerson.id)) { if (hasValue(ePerson.id)) {
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: this.dsoNameService.getName(ePerson)}));
} 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);
} }
@@ -284,14 +287,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
/** /**
* This method will set everything to stale, which will cause the lists on this page to update. * This method will set everything to stale, which will cause the lists on this page to update.
*/ */
reset() { reset(): void {
this.epersonService.getBrowseEndpoint().pipe( this.epersonService.getBrowseEndpoint().pipe(
take(1) take(1),
).subscribe((href: string) => { switchMap((href: string) => {
this.requestService.setStaleByHrefSubstring(href).pipe(take(1)).subscribe(() => { return this.requestService.setStaleByHrefSubstring(href).pipe(
this.epersonService.cancelEditEPerson(); take(1),
this.isEPersonFormShown = false; );
}); })
).subscribe(()=>{
this.epersonService.cancelEditEPerson();
this.isEPersonFormShown = false;
}); });
} }
} }

View File

@@ -13,12 +13,13 @@
[formGroup]="formGroup" [formGroup]="formGroup"
[formLayout]="formLayout" [formLayout]="formLayout"
[displayCancel]="false" [displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()"> (submitForm)="onSubmit()">
<div before class="btn-group"> <div before class="btn-group">
<button (click)="onCancel()" <button (click)="onCancel()"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button> class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div> </div>
<div between class="btn-group"> <div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()"> <button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}} <i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button> </button>
@@ -64,9 +65,13 @@
<tbody> <tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page"> <tr *ngFor="let group of (groups | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td> <td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)" <td class="align-middle">
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td> <a (click)="groupsDataService.startEditingNewGroup(group)"
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td> [routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -2,7 +2,7 @@ import { Observable, of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
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 { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
@@ -116,9 +116,9 @@ describe('EPersonFormComponent', () => {
const controlModel = model; const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled }; const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions); controls[model.id] = new UntypedFormControl(controlState, controlOptions);
}); });
return new FormGroup(controls, options); return new UntypedFormGroup(controls, options);
}, },
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return { return {
@@ -537,7 +537,7 @@ describe('EPersonFormComponent', () => {
}); });
it('should call epersonRegistrationService.registerEmail', () => { it('should call epersonRegistrationService.registerEmail', () => {
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail); expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail, null, 'forgot');
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { import {
DynamicCheckboxModel, DynamicCheckboxModel,
DynamicFormControlModel, DynamicFormControlModel,
@@ -8,7 +8,7 @@ import {
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { debounceTime, switchMap, take } from 'rxjs/operators'; import { debounceTime, finalize, map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { 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';
@@ -36,6 +36,8 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { Registration } from '../../../core/shared/registration.model'; import { Registration } from '../../../core/shared/registration.model';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
@@ -107,7 +109,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
/** /**
* A FormGroup that combines all inputs * A FormGroup that combines all inputs
*/ */
formGroup: FormGroup; formGroup: UntypedFormGroup;
/** /**
* An EventEmitter that's fired whenever the form is being submitted * An EventEmitter that's fired whenever the form is being submitted
@@ -164,6 +166,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
isImpersonated = false; isImpersonated = false;
/**
* A boolean that indicate if to display EPersonForm's Rest password button
*/
displayResetPassword = false;
/**
* A string that indicate the label of Submit button
*/
submitLabel = 'form.create';
/** /**
* Subscription to email field value change * Subscription to email field value change
*/ */
@@ -182,11 +193,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
private paginationService: PaginationService, private paginationService: PaginationService,
public requestService: RequestService, public requestService: RequestService,
private epersonRegistrationService: EpersonRegistrationService, private epersonRegistrationService: EpersonRegistrationService,
public dsoNameService: DSONameService,
) { ) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson; this.epersonInitial = eperson;
if (hasValue(eperson)) { if (hasValue(eperson)) {
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
this.displayResetPassword = true;
this.submitLabel = 'form.submit';
} }
})); }));
} }
@@ -200,14 +214,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
initialisePage() { initialisePage() {
observableCombineLatest( observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`), this.translateService.get(`${this.messagePrefix}.lastName`),
this.translateService.get(`${this.messagePrefix}.email`), this.translateService.get(`${this.messagePrefix}.email`),
this.translateService.get(`${this.messagePrefix}.canLogIn`), this.translateService.get(`${this.messagePrefix}.canLogIn`),
this.translateService.get(`${this.messagePrefix}.requireCertificate`), this.translateService.get(`${this.messagePrefix}.requireCertificate`),
this.translateService.get(`${this.messagePrefix}.emailHint`), this.translateService.get(`${this.messagePrefix}.emailHint`),
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { ]).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
this.firstName = new DynamicInputModel({ this.firstName = new DynamicInputModel({
id: 'firstName', id: 'firstName',
label: firstName, label: firstName,
@@ -374,10 +388,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
getFirstCompletedRemoteData() getFirstCompletedRemoteData()
).subscribe((rd: RemoteData<EPerson>) => { ).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.submitForm.emit(ePersonToCreate); this.submitForm.emit(ePersonToCreate);
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.cancelForm.emit(); this.cancelForm.emit();
} }
}); });
@@ -413,10 +427,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
const response = this.epersonService.updateEPerson(editedEperson); const response = this.epersonService.updateEPerson(editedEperson);
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => { response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) }));
this.submitForm.emit(editedEperson); this.submitForm.emit(editedEperson);
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) }));
this.cancelForm.emit(); this.cancelForm.emit();
} }
}); });
@@ -449,31 +463,42 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing. * Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not. * It'll either show a success or error message depending on whether the delete was successful or not.
*/ */
delete() { delete(): void {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { this.epersonService.getActiveEPerson().pipe(
const modalRef = this.modalService.open(ConfirmationModalComponent); take(1),
modalRef.componentInstance.dso = eperson; switchMap((eperson: EPerson) => {
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.brandColor = 'danger'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmIcon = 'fas fa-trash'; modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { modalRef.componentInstance.brandColor = 'danger';
if (confirm) { modalRef.componentInstance.confirmIcon = 'fas fa-trash';
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => { return modalRef.componentInstance.response.pipe(
if (restResponse.hasSucceeded) { take(1),
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name })); switchMap((confirm: boolean) => {
this.submitForm.emit(); if (confirm && hasValue(eperson.id)) {
} else { this.canDelete$ = observableOf(false);
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); return this.epersonService.deleteEPerson(eperson).pipe(
} getFirstCompletedRemoteData(),
this.cancelForm.emit(); map((restResponse: RemoteData<NoContent>) => ({ restResponse, eperson }))
}); );
} } else {
} return observableOf(null);
}); }
}),
finalize(() => this.canDelete$ = observableOf(true))
);
})
).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => {
if (restResponse?.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) }));
} else {
this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`);
}
this.cancelForm.emit();
}); });
} }
@@ -491,7 +516,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
resetPassword() { resetPassword() {
if (hasValue(this.epersonInitial.email)) { if (hasValue(this.epersonInitial.email)) {
this.epersonRegistrationService.registerEmail(this.epersonInitial.email).pipe(getFirstCompletedRemoteData()) this.epersonRegistrationService.registerEmail(this.epersonInitial.email, null, TYPE_REQUEST_FORGOT).pipe(getFirstCompletedRemoteData())
.subscribe((response: RemoteData<Registration>) => { .subscribe((response: RemoteData<Registration>) => {
if (response.hasSucceeded) { if (response.hasSucceeded) {
this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'), this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'),
@@ -509,7 +534,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Cancel the current edit when component is destroyed & unsub all subscriptions * Cancel the current edit when component is destroyed & unsub all subscriptions
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config.id);
if (hasValue(this.emailValueChangeSubscribe)) { if (hasValue(this.emailValueChangeSubscribe)) {
@@ -542,7 +566,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
.subscribe((list: PaginatedList<EPerson>) => { .subscribe((list: PaginatedList<EPerson>) => {
if (list.totalElements > 0) { if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
name: ePerson.name, name: this.dsoNameService.getName(ePerson),
email: ePerson.email email: ePerson.email
})); }));
} }

View File

@@ -9,13 +9,24 @@
</ng-template> </ng-template>
<ng-template #editheader> <ng-template #editheader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2> <h2 class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
id: 'edit-group-page',
iconPlacement: 'right',
tooltipPlacement: ['right', 'bottom']
}"
>
{{messagePrefix + '.head.edit' | translate}}
</span>
</h2>
</ng-template> </ng-template>
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning" <ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
[content]="messagePrefix + '.alert.permanent'"></ds-alert> [content]="messagePrefix + '.alert.permanent'"></ds-alert>
<ds-alert *ngIf="!(canEdit$ | async) && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning" <ds-alert *ngIf="!(canEdit$ | async) && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: (getLinkedDSO(groupBeingEdited) | async)?.payload?.name, comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })"> [content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName((getLinkedDSO(groupBeingEdited) | async)?.payload), comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })">
</ds-alert> </ds-alert>
<ds-form [formId]="formId" <ds-form [formId]="formId"

View File

@@ -2,7 +2,7 @@ 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 { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { BrowserModule, By } 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';
@@ -36,6 +36,8 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications-
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'; import { NoContent } from '../../../core/shared/NoContent.model';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock';
describe('GroupFormComponent', () => { describe('GroupFormComponent', () => {
let component: GroupFormComponent; let component: GroupFormComponent;
@@ -130,9 +132,9 @@ describe('GroupFormComponent', () => {
const controlModel = model; const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled }; const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn); const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions); controls[model.id] = new UntypedFormControl(controlState, controlOptions);
}); });
return new FormGroup(controls, options); return new UntypedFormGroup(controls, options);
}, },
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) { createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return { return {
@@ -188,7 +190,7 @@ describe('GroupFormComponent', () => {
translateService = getMockTranslateService(); translateService = getMockTranslateService();
router = new RouterMock(); router = new RouterMock();
notificationService = new NotificationsServiceStub(); notificationService = new NotificationsServiceStub();
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
@@ -198,7 +200,8 @@ describe('GroupFormComponent', () => {
}), }),
], ],
declarations: [GroupFormComponent], declarations: [GroupFormComponent],
providers: [GroupFormComponent, providers: [
{ provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: GroupDataService, useValue: groupsDataServiceStub }, { provide: GroupDataService, useValue: groupsDataServiceStub },
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub }, { provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
@@ -240,8 +243,8 @@ describe('GroupFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit a new group using the correct values', waitForAsync(() => { it('should emit a new group using the correct values', (async () => {
fixture.whenStable().then(() => { await fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected); expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
}); });
})); }));
@@ -303,8 +306,8 @@ describe('GroupFormComponent', () => {
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
}); });
it('should emit the existing group using the correct new values', waitForAsync(() => { it('should emit the existing group using the correct new values', (async () => {
fixture.whenStable().then(() => { await fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
}); });
})); }));

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core'; import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { import {
@@ -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 { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
@Component({ @Component({
@@ -95,7 +96,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
/** /**
* A FormGroup that combines all inputs * A FormGroup that combines all inputs
*/ */
formGroup: FormGroup; formGroup: UntypedFormGroup;
/** /**
* An EventEmitter that's fired whenever the form is being submitted * An EventEmitter that's fired whenever the form is being submitted
@@ -134,7 +135,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
groupNameValueChangeSubscribe: Subscription; groupNameValueChangeSubscribe: Subscription;
constructor(public groupDataService: GroupDataService, constructor(
public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService, private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService, private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService, private formBuilderService: FormBuilderService,
@@ -145,7 +147,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private modalService: NgbModal, private modalService: NgbModal,
public requestService: RequestService, public requestService: RequestService,
protected changeDetectorRef: ChangeDetectorRef) { protected changeDetectorRef: ChangeDetectorRef,
public dsoNameService: DSONameService,
) {
} }
ngOnInit() { ngOnInit() {
@@ -331,7 +335,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
.subscribe((list: PaginatedList<Group>) => { .subscribe((list: PaginatedList<Group>) => {
if (list.totalElements > 0) { if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', {
name: group.name name: this.dsoNameService.getName(group),
})); }));
} }
})); }));
@@ -364,10 +368,10 @@ export class GroupFormComponent implements OnInit, OnDestroy {
getFirstCompletedRemoteData() getFirstCompletedRemoteData()
).subscribe((rd: RemoteData<Group>) => { ).subscribe((rd: RemoteData<Group>) => {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: rd.payload.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: this.dsoNameService.getName(rd.payload) }));
this.submitForm.emit(rd.payload); this.submitForm.emit(rd.payload);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.edited.failure', { name: group.name })); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.edited.failure', { name: this.dsoNameService.getName(group) }));
this.cancelForm.emit(); this.cancelForm.emit();
} }
}); });
@@ -427,11 +431,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupDataService.delete(group.id).pipe(getFirstCompletedRemoteData()) this.groupDataService.delete(group.id).pipe(getFirstCompletedRemoteData())
.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: this.dsoNameService.getName(group) }));
this.onCancel(); 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: this.dsoNameService.getName(group) }),
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.content', { cause: rd.errorMessage })); this.translateService.get(this.messagePrefix + '.notification.deleted.failure.content', { cause: rd.errorMessage }));
} }
}); });

View File

@@ -1,9 +1,19 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}} <h4 id="search" class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div> <div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> <select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
@@ -47,26 +57,32 @@
<tbody> <tbody>
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page"> <tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td> <td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)" <td class="align-middle">
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td> <a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }}
</a>
</td>
<td class="align-middle"> <td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/> {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="(ePerson.memberOfGroup)" <button *ngIf="ePerson.memberOfGroup"
(click)="deleteMemberFromGroup(ePerson)" (click)="deleteMemberFromGroup(ePerson)"
class="btn btn-outline-danger btn-sm" [disabled]="actionConfig.remove.disabled"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}"> [ngClass]="['btn btn-sm', actionConfig.remove.css]"
<i class="fas fa-trash-alt fa-fw"></i> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button> </button>
<button *ngIf="!(ePerson.memberOfGroup)" <button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)" (click)="addMemberToGroup(ePerson)"
class="btn btn-outline-primary btn-sm" [disabled]="actionConfig.add.disabled"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.eperson.name} }}"> [ngClass]="['btn btn-sm', actionConfig.add.css]"
<i class="fas fa-plus fa-fw"></i> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
</td> </td>
@@ -105,18 +121,31 @@
<tbody> <tbody>
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page"> <tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td> <td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)" <td class="align-middle">
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td> <a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }}
</a>
</td>
<td class="align-middle"> <td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/> {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(ePerson)" <button *ngIf="ePerson.memberOfGroup"
class="btn btn-outline-danger btn-sm" (click)="deleteMemberFromGroup(ePerson)"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}"> [disabled]="actionConfig.remove.disabled"
<i class="fas fa-trash-alt fa-fw"></i> [ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
</td> </td>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
@@ -28,6 +28,8 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio
import { RouterMock } from '../../../../shared/mocks/router.mock'; 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 { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
describe('MembersListComponent', () => { describe('MembersListComponent', () => {
let component: MembersListComponent; let component: MembersListComponent;
@@ -37,10 +39,10 @@ describe('MembersListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let allEPersons; let allEPersons: EPerson[];
let allGroups; let allGroups: Group[];
let epersonMembers; let epersonMembers: EPerson[];
let subgroupMembers; let subgroupMembers: Group[];
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -53,7 +55,7 @@ describe('MembersListComponent', () => {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, subgroupMembers: subgroupMembers,
findListByHref(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>>> {
@@ -118,7 +120,7 @@ describe('MembersListComponent', () => {
translateService = getMockTranslateService(); translateService = getMockTranslateService();
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
@@ -135,6 +137,7 @@ describe('MembersListComponent', () => {
{ provide: FormBuilderService, useValue: builderService }, { provide: FormBuilderService, useValue: builderService },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: DSONameService, useValue: new DSONameServiceMock() },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -147,8 +150,10 @@ describe('MembersListComponent', () => {
}); });
afterEach(fakeAsync(() => { afterEach(fakeAsync(() => {
fixture.destroy(); fixture.destroy();
fixture.debugElement.nativeElement.remove();
flush(); flush();
component = null; component = null;
fixture.debugElement.nativeElement.remove();
})); }));
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => { it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {
@@ -167,12 +172,19 @@ describe('MembersListComponent', () => {
describe('search', () => { describe('search', () => {
describe('when searching without query', () => { describe('when searching without query', () => {
let epersonsFound; let epersonsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
return observableOf(activeGroup.epersons.includes(ePerson));
});
component.search({ scope: 'metadata', query: '' }); component.search({ scope: 'metadata', query: '' });
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
// Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything
// because they don't change the value of activeGroup.epersons)
jasmine.getEnv().allowRespy(true);
spyOn(component, 'isMemberOfGroup').and.callThrough();
})); }));
it('should display all epersons', () => { it('should display all epersons', () => {
@@ -181,62 +193,56 @@ describe('MembersListComponent', () => {
describe('if eperson is already a eperson', () => { describe('if eperson is already a eperson', () => {
it('should have delete button, else it should have add button', () => { it('should have delete button, else it should have add button', () => {
activeGroup.epersons.map((eperson: EPerson) => { const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id);
epersonsFound.map((foundEPersonRowElement) => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
if (foundEPersonRowElement.debugElement !== undefined) { const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child'));
const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child')); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); if (memberIds.includes(epersonId.nativeElement.textContent)) {
if (epersonId.nativeElement.textContent === eperson.id) { expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined(); } else {
} else { expect(deleteButton).toBeNull();
expect(deleteButton).toBeUndefined(); expect(addButton).not.toBeNull();
expect(addButton).toBeDefined(); }
}
}
});
}); });
}); });
}); });
describe('if first add button is pressed', () => { describe('if first add button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click(); addButton.nativeElement.click();
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
})); }));
it('all groups in search member of selected group', () => { it('then all the ePersons are member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2); expect(epersonsFound.length).toEqual(2);
epersonsFound.map((foundEPersonRowElement) => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
if (foundEPersonRowElement.debugElement !== undefined) { const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined();
}
}); });
}); });
}); });
describe('if first delete button is pressed', () => { describe('if first delete button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
addButton.nativeElement.click(); deleteButton.nativeElement.click();
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
})); }));
it('first eperson in search delete button, because now member', () => { it('then no ePerson is member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
epersonsFound.map((foundEPersonRowElement) => { expect(epersonsFound.length).toEqual(2);
if (foundEPersonRowElement.debugElement !== undefined) { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(deleteButton).toBeUndefined(); expect(deleteButton).toBeNull();
expect(addButton).toBeDefined(); expect(addButton).not.toBeNull();
}
}); });
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { UntypedFormBuilder } 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 { import {
@@ -11,7 +11,7 @@ import {
ObservedValueOf, ObservedValueOf,
} from 'rxjs'; } from 'rxjs';
import { defaultIfEmpty, 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';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
@@ -19,12 +19,15 @@ import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { import {
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstCompletedRemoteData, getAllCompletedRemoteData, getRemoteDataPayload getFirstCompletedRemoteData,
getAllCompletedRemoteData,
getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import {EpersonDtoModel} from '../../../../core/eperson/models/eperson-dto.model'; import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
/** /**
* Keys to keep track of specific subscriptions * Keys to keep track of specific subscriptions
@@ -35,6 +38,35 @@ enum SubKey {
SearchResultsDTO, SearchResultsDTO,
} }
/**
* The layout config of the buttons in the last column
*/
export interface EPersonActionConfig {
/**
* The css classes that should be added to the button
*/
css?: string;
/**
* Whether the button should be disabled
*/
disabled: boolean;
/**
* The Font Awesome icon that should be used
*/
icon: string;
}
/**
* The {@link EPersonActionConfig} that should be used to display the button. The remove config will be used when the
* {@link EPerson} is already a member of the {@link Group} and the remove config will be used otherwise.
*
* *See {@link actionConfig} for an example*
*/
export interface EPersonListActionConfig {
add: EPersonActionConfig;
remove: EPersonActionConfig;
}
@Component({ @Component({
selector: 'ds-members-list', selector: 'ds-members-list',
templateUrl: './members-list.component.html' templateUrl: './members-list.component.html'
@@ -47,6 +79,20 @@ export class MembersListComponent implements OnInit, OnDestroy {
@Input() @Input()
messagePrefix: string; messagePrefix: string;
@Input()
actionConfig: EPersonListActionConfig = {
add: {
css: 'btn-outline-primary',
disabled: false,
icon: 'fas fa-plus fa-fw',
},
remove: {
css: 'btn-outline-danger',
disabled: false,
icon: 'fas fa-trash-alt fa-fw'
},
};
/** /**
* EPeople being displayed in search result, initially all members, after search result of search * EPeople being displayed in search result, initially all members, after search result of search
*/ */
@@ -91,21 +137,21 @@ export class MembersListComponent implements OnInit, OnDestroy {
// current active group being edited // current active group being edited
groupBeingEdited: Group; groupBeingEdited: Group;
paginationSub: Subscription; constructor(
protected groupDataService: GroupDataService,
public ePersonDataService: EPersonDataService,
constructor(private groupDataService: GroupDataService, protected translateService: TranslateService,
public ePersonDataService: EPersonDataService, protected notificationsService: NotificationsService,
private translateService: TranslateService, protected formBuilder: UntypedFormBuilder,
private notificationsService: NotificationsService, protected paginationService: PaginationService,
private formBuilder: FormBuilder, protected router: Router,
private paginationService: PaginationService, public dsoNameService: DSONameService,
private router: Router) { ) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata'; this.currentSearchScope = 'metadata';
} }
ngOnInit() { ngOnInit(): void {
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
scope: 'metadata', scope: 'metadata',
query: '', query: '',
@@ -124,7 +170,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param page the number of the page to retrieve * @param page the number of the page to retrieve
* @private * @private
*/ */
private retrieveMembers(page: number) { retrieveMembers(page: number): void {
this.unsubFrom(SubKey.MembersDTO); this.unsubFrom(SubKey.MembersDTO);
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(
@@ -135,36 +181,36 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
); );
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
if (rd.hasFailed) { if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage})); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else { } else {
return rd; return rd;
} }
}), }),
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();
epersonDtoModel.eperson = member; epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember; epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})]); })]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
})); }));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
}));
} }
/** /**
* Whether or not the given ePerson is a member of the group currently being edited * Whether the given ePerson is a member of the group currently being edited
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited * @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
*/ */
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> { isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
@@ -193,7 +239,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param key The key of the subscription to unsubscribe from * @param key The key of the subscription to unsubscribe from
* @private * @private
*/ */
private unsubFrom(key: SubKey) { protected unsubFrom(key: SubKey) {
if (this.subs.has(key)) { if (this.subs.has(key)) {
this.subs.get(key).unsubscribe(); this.subs.get(key).unsubscribe();
this.subs.delete(key); this.subs.delete(key);
@@ -205,10 +251,11 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param ePerson EPerson we want to delete as member from group that is currently being edited * @param ePerson EPerson we want to delete as member from group that is currently being edited
*/ */
deleteMemberFromGroup(ePerson: EpersonDtoModel) { deleteMemberFromGroup(ePerson: EpersonDtoModel) {
ePerson.memberOfGroup = false;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
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, this.dsoNameService.getName(ePerson.eperson), activeGroup);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -224,7 +271,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson);
this.showNotifications('addMember', response, ePerson.eperson.name, activeGroup); this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -267,7 +314,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
if (rd.hasFailed) { if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage})); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else { } else {
return rd; return rd;
} }

View File

@@ -1,7 +1,16 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}} <h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
id: 'edit-group-add-subgroups',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
@@ -44,24 +53,28 @@
<tbody> <tbody>
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page"> <tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td> <td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)" <td class="align-middle">
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td> <a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td> [routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)" <button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="deleteSubgroupFromGroup(group)" (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton" class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
<p *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</p> <span *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</span>
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)" <button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="addSubgroupToGroup(group)" (click)="addSubgroupToGroup(group)"
class="btn btn-outline-primary btn-sm addButton" class="btn btn-outline-primary btn-sm addButton"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: group.name} }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-plus fa-fw"></i> <i class="fas fa-plus fa-fw"></i>
</button> </button>
</div> </div>
@@ -99,14 +112,18 @@
<tbody> <tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page"> <tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td> <td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)" <td class="align-middle">
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td> <a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td> [routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)" <button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton" class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
</div> </div>

View File

@@ -1,14 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
import { import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
ComponentFixture,
fakeAsync,
flush,
inject,
TestBed,
tick,
waitForAsync
} from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -37,6 +29,8 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
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 { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
describe('SubgroupsListComponent', () => { describe('SubgroupsListComponent', () => {
let component: SubgroupsListComponent; let component: SubgroupsListComponent;
@@ -46,8 +40,8 @@ describe('SubgroupsListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let subgroups; let subgroups: Group[];
let allGroups; let allGroups: Group[];
let routerStub; let routerStub;
let paginationService; let paginationService;
@@ -65,7 +59,7 @@ describe('SubgroupsListComponent', () => {
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.activeGroup;
}, },
findListByHref(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));
@@ -116,6 +110,7 @@ describe('SubgroupsListComponent', () => {
], ],
declarations: [SubgroupsListComponent], declarations: [SubgroupsListComponent],
providers: [SubgroupsListComponent, providers: [SubgroupsListComponent,
{ provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: GroupDataService, useValue: groupsDataServiceStub }, { provide: GroupDataService, useValue: groupsDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: FormBuilderService, useValue: builderService }, { provide: FormBuilderService, useValue: builderService },
@@ -133,6 +128,7 @@ describe('SubgroupsListComponent', () => {
}); });
afterEach(fakeAsync(() => { afterEach(fakeAsync(() => {
fixture.destroy(); fixture.destroy();
fixture.debugElement.nativeElement.remove();
flush(); flush();
component = null; component = null;
})); }));
@@ -152,7 +148,7 @@ describe('SubgroupsListComponent', () => {
}); });
describe('if first group delete button is pressed', () => { describe('if first group delete button is pressed', () => {
let groupsFound; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
addButton.triggerEventHandler('click', { addButton.triggerEventHandler('click', {
@@ -170,7 +166,7 @@ describe('SubgroupsListComponent', () => {
describe('search', () => { describe('search', () => {
describe('when searching with empty query', () => { describe('when searching with empty query', () => {
let groupsFound; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ query: '' }); component.search({ query: '' });
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
@@ -181,9 +177,9 @@ describe('SubgroupsListComponent', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
expect(groupsFound.length).toEqual(2); expect(groupsFound.length).toEqual(2);
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
allGroups.map((group: Group) => { allGroups.map((group: Group) => {
expect(groupIdsFound.find((foundEl) => { expect(groupIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
@@ -195,30 +191,30 @@ describe('SubgroupsListComponent', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
if (getSubgroups !== undefined && getSubgroups.length > 0) { if (getSubgroups !== undefined && getSubgroups.length > 0) {
groupsFound.map((foundGroupRowElement) => { groupsFound.map((foundGroupRowElement: DebugElement) => {
if (foundGroupRowElement.debugElement !== undefined) { const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeUndefined(); expect(addButton).toBeNull();
expect(deleteButton).toBeDefined(); if (activeGroup.id === groupId.nativeElement.textContent) {
expect(deleteButton).toBeNull();
} else {
expect(deleteButton).not.toBeNull();
} }
}); });
} else { } else {
getSubgroups.map((group: Group) => { const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id);
groupsFound.map((foundGroupRowElement) => { groupsFound.map((foundGroupRowElement: DebugElement) => {
if (foundGroupRowElement.debugElement !== undefined) { const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const groupId = foundGroupRowElement.debugElement.query(By.css('td:first-child')); const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus')); const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); if (subgroupIds.includes(groupId.nativeElement.textContent)) {
if (groupId.nativeElement.textContent === group.id) { expect(addButton).toBeNull();
expect(addButton).toBeUndefined(); expect(deleteButton).not.toBeNull();
expect(deleteButton).toBeDefined(); } else {
} else { expect(deleteButton).toBeNull();
expect(deleteButton).toBeUndefined(); expect(addButton).not.toBeNull();
expect(addButton).toBeDefined(); }
}
}
});
}); });
} }
}); });

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { UntypedFormBuilder } 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 { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
@@ -18,6 +18,7 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
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 { followLink } from '../../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
/** /**
* Keys to keep track of specific subscriptions * Keys to keep track of specific subscriptions
@@ -86,9 +87,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
constructor(public groupDataService: GroupDataService, constructor(public groupDataService: GroupDataService,
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private paginationService: PaginationService, private paginationService: PaginationService,
private router: Router) { private router: Router,
public dsoNameService: DSONameService,
) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
} }
@@ -177,7 +180,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup); const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
this.showNotifications('deleteSubgroup', response, subgroup.name, activeGroup); this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -193,7 +196,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
if (activeGroup.uuid !== subgroup.uuid) { if (activeGroup.uuid !== subgroup.uuid) {
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup); const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
this.showNotifications('addSubgroup', response, subgroup.name, activeGroup); this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
} }

View File

@@ -7,7 +7,7 @@
<button class="mr-auto btn btn-success" <button class="mr-auto btn btn-success"
[routerLink]="['newGroup']"> [routerLink]="['newGroup']">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">{{messagePrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -17,7 +17,7 @@
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 mr-3">
<div class="form-group input-group"> <div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" attr.aria-label="{{messagePrefix + 'search.placeholder' | translate}}" class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" > [placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
@@ -56,8 +56,8 @@
<tbody> <tbody>
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page"> <tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td> <td>{{groupDto.group.id}}</td>
<td>{{groupDto.group.name}}</td> <td>{{ dsoNameService.getName(groupDto.group) }}</td>
<td>{{(groupDto.group.object | async)?.payload?.name}}</td> <td>{{ dsoNameService.getName((groupDto.group.object | async)?.payload) }}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td> <td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">
@@ -65,7 +65,7 @@
<button *ngSwitchCase="true" <button *ngSwitchCase="true"
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)" [routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
class="btn btn-outline-primary btn-sm btn-edit" class="btn btn-outline-primary btn-sm btn-edit"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}" title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: dsoNameService.getName(groupDto.group) } }}"
> >
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
@@ -80,7 +80,7 @@
</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 btn-delete" (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: dsoNameService.getName(groupDto.group) } }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
</div> </div>

View File

@@ -32,8 +32,10 @@ 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'; import { NoContent } from '../../core/shared/NoContent.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock, UNDEFINED_NAME } from '../../shared/mocks/dso-name.service.mock';
describe('GroupRegistryComponent', () => { describe('GroupsRegistryComponent', () => {
let component: GroupsRegistryComponent; let component: GroupsRegistryComponent;
let fixture: ComponentFixture<GroupsRegistryComponent>; let fixture: ComponentFixture<GroupsRegistryComponent>;
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
@@ -160,7 +162,7 @@ describe('GroupRegistryComponent', () => {
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
setIsAuthorized(true, true); setIsAuthorized(true, true);
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
@@ -171,6 +173,7 @@ describe('GroupRegistryComponent', () => {
], ],
declarations: [GroupsRegistryComponent], declarations: [GroupsRegistryComponent],
providers: [GroupsRegistryComponent, providers: [GroupsRegistryComponent,
{ provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: GroupDataService, useValue: groupsDataServiceStub }, { provide: GroupDataService, useValue: groupsDataServiceStub },
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub }, { provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
@@ -208,7 +211,7 @@ describe('GroupRegistryComponent', () => {
it('should display community/collection name if present', () => { it('should display community/collection name if present', () => {
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)')); const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
expect(collectionNamesFound.length).toEqual(2); expect(collectionNamesFound.length).toEqual(2);
expect(collectionNamesFound[0].nativeElement.textContent).toEqual(''); expect(collectionNamesFound[0].nativeElement.textContent).toEqual(UNDEFINED_NAME);
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName'); expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
}); });

View File

@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { UntypedFormBuilder } 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 { import {
@@ -37,6 +37,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
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 { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@Component({ @Component({
selector: 'ds-groups-registry', selector: 'ds-groups-registry',
@@ -99,12 +100,14 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
private dSpaceObjectDataService: DSpaceObjectDataService, private dSpaceObjectDataService: DSpaceObjectDataService,
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
protected routeService: RouteService, protected routeService: RouteService,
private router: Router, private router: Router,
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private paginationService: PaginationService, private paginationService: PaginationService,
public requestService: RequestService) { public requestService: RequestService,
public dsoNameService: DSONameService,
) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
query: this.currentSearchQuery, query: this.currentSearchQuery,
@@ -201,10 +204,10 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
.subscribe((rd: RemoteData<NoContent>) => { .subscribe((rd: RemoteData<NoContent>) => {
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: this.dsoNameService.getName(group.group) }));
} 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: this.dsoNameService.getName(group.group) }),
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: rd.errorMessage })); this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: rd.errorMessage }));
} }
}); });

View File

@@ -20,12 +20,29 @@
</small> </small>
</div> </div>
<ui-switch color="#ebebeb"
[checkedLabel]="'admin.metadata-import.page.toggle.upload' | translate"
[uncheckedLabel]="'admin.metadata-import.page.toggle.url' | translate"
[checked]="isUpload"
(change)="toggleUpload()" ></ui-switch>
<small class="form-text text-muted">
{{'admin.batch-import.page.toggle.help' | translate}}
</small>
<ds-file-dropzone-no-uploader <ds-file-dropzone-no-uploader
*ngIf="isUpload"
data-test="file-dropzone"
(onFileAdded)="setFile($event)" (onFileAdded)="setFile($event)"
[dropMessageLabel]="'admin.batch-import.page.dropMsg'" [dropMessageLabel]="'admin.batch-import.page.dropMsg'"
[dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'"> [dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader> </ds-file-dropzone-no-uploader>
<div class="form-group mt-2" *ngIf="!isUpload">
<input class="form-control" type="text" placeholder="{{'admin.metadata-import.page.urlMsg' | translate}}"
data-test="file-url-input" [(ngModel)]="fileURL">
</div>
<div class="space-children-mr"> <div class="space-children-mr">
<button class="btn btn-secondary" id="backButton" <button class="btn btn-secondary" id="backButton"
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button> (click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>

View File

@@ -86,10 +86,18 @@ describe('BatchImportPageComponent', () => {
let fileMock: File; let fileMock: File;
beforeEach(() => { beforeEach(() => {
component.isUpload = true;
fileMock = new File([''], 'filename.zip', { type: 'application/zip' }); fileMock = new File([''], 'filename.zip', { type: 'application/zip' });
component.setFile(fileMock); component.setFile(fileMock);
}); });
it('should show the file dropzone', () => {
const fileDropzone = fixture.debugElement.query(By.css('[data-test="file-dropzone"]'));
const fileUrlInput = fixture.debugElement.query(By.css('[data-test="file-url-input"]'));
expect(fileDropzone).toBeTruthy();
expect(fileUrlInput).toBeFalsy();
});
describe('if proceed button is pressed without validate only', () => { describe('if proceed button is pressed without validate only', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.validateOnly = false; component.validateOnly = false;
@@ -99,9 +107,9 @@ describe('BatchImportPageComponent', () => {
})); }));
it('metadata-import script is invoked with --zip fileName and the mockFile', () => { it('metadata-import script is invoked with --zip fileName and the mockFile', () => {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), Object.assign(new ProcessParameter(), { name: '--add' }),
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]); expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
}); });
it('success notification is shown', () => { it('success notification is shown', () => {
@@ -121,8 +129,8 @@ describe('BatchImportPageComponent', () => {
})); }));
it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => { it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
Object.assign(new ProcessParameter(), { name: '--add' }), Object.assign(new ProcessParameter(), { name: '--add' }),
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
Object.assign(new ProcessParameter(), { name: '-v', value: true }), Object.assign(new ProcessParameter(), { name: '-v', value: true }),
]; ];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
@@ -148,4 +156,77 @@ describe('BatchImportPageComponent', () => {
}); });
}); });
}); });
describe('if url is set', () => {
beforeEach(fakeAsync(() => {
component.isUpload = false;
component.fileURL = 'example.fileURL.com';
fixture.detectChanges();
}));
it('should show the file url input', () => {
const fileDropzone = fixture.debugElement.query(By.css('[data-test="file-dropzone"]'));
const fileUrlInput = fixture.debugElement.query(By.css('[data-test="file-url-input"]'));
expect(fileDropzone).toBeFalsy();
expect(fileUrlInput).toBeTruthy();
});
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 --url and the file url', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--add' }),
Object.assign(new ProcessParameter(), { name: '--url', value: 'example.fileURL.com' })
];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [null]);
});
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 --url and the file url and -v validate-only', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--add' }),
Object.assign(new ProcessParameter(), { name: '--url', value: 'example.fileURL.com' }),
Object.assign(new ProcessParameter(), { name: '-v', value: true }),
];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [null]);
});
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

@@ -8,7 +8,7 @@ import { ProcessParameter } from '../../process-page/processes/process-parameter
import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Process } from '../../process-page/processes/process.model'; import { Process } from '../../process-page/processes/process.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
import { import {
ImportBatchSelectorComponent ImportBatchSelectorComponent
@@ -32,11 +32,22 @@ export class BatchImportPageComponent {
* The validate only flag * The validate only flag
*/ */
validateOnly = true; validateOnly = true;
/** /**
* dso object for community or collection * dso object for community or collection
*/ */
dso: DSpaceObject = null; dso: DSpaceObject = null;
/**
* The flag between upload and url
*/
isUpload = true;
/**
* File URL when flag is for url
*/
fileURL: string;
public constructor(private location: Location, public constructor(private location: Location,
protected translate: TranslateService, protected translate: TranslateService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
@@ -72,13 +83,22 @@ export class BatchImportPageComponent {
* Starts import-metadata script with --zip fileName (and the selected file) * Starts import-metadata script with --zip fileName (and the selected file)
*/ */
public importMetadata() { public importMetadata() {
if (this.fileObject == null) { if (this.fileObject == null && isEmpty(this.fileURL)) {
this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); if (this.isUpload) {
this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile'));
} else {
this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFileUrl'));
}
} else { } else {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }),
Object.assign(new ProcessParameter(), { name: '--add' }) Object.assign(new ProcessParameter(), { name: '--add' })
]; ];
if (this.isUpload) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }));
} else {
this.fileObject = null;
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--url', value: this.fileURL }));
}
if (this.dso) { if (this.dso) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid }));
} }
@@ -97,9 +117,15 @@ export class BatchImportPageComponent {
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
} }
} else { } else {
const title = this.translate.get('process.new.notification.error.title'); if (rd.statusCode === 413) {
const content = this.translate.get('process.new.notification.error.content'); const title = this.translate.get('process.new.notification.error.title');
this.notificationsService.error(title, content); const content = this.translate.get('process.new.notification.error.max-upload.content');
this.notificationsService.error(title, content);
} 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);
}
} }
}); });
} }
@@ -121,4 +147,11 @@ export class BatchImportPageComponent {
removeDspaceObject(): void { removeDspaceObject(): void {
this.dso = null; this.dso = null;
} }
/**
* toggle the flag between upload and url
*/
toggleUpload() {
this.isUpload = !this.isUpload;
}
} }

View File

@@ -29,7 +29,7 @@
<tbody> <tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page"> <tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async" [checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)" (change)="selectBitStreamFormat(bitstreamFormat, $event)"

View File

@@ -29,7 +29,7 @@
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page" <tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
[ngClass]="{'table-primary' : isActive(schema) | async}"> [ngClass]="{'table-primary' : isActive(schema) | async}">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(schema) | async" [checked]="isSelected(schema) | async"
(change)="selectMetadataSchema(schema, $event)" (change)="selectMetadataSchema(schema, $event)"

View File

@@ -1,3 +1,7 @@
.selectable-row:hover { .selectable-row:hover {
cursor: pointer; cursor: pointer;
} }
:host ::ng-deep #metadatadataschemagroup {
display: flex;
}

View File

@@ -1,5 +1,4 @@
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { MetadataSchemaFormComponent } from './metadata-schema-form.component'; import { MetadataSchemaFormComponent } from './metadata-schema-form.component';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -29,14 +28,16 @@ describe('MetadataSchemaFormComponent', () => {
createFormGroup: () => { createFormGroup: () => {
return { return {
patchValue: () => { patchValue: () => {
} },
reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void {
},
}; };
} }
}; };
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [MetadataSchemaFormComponent, EnumKeysPipe], declarations: [MetadataSchemaFormComponent, EnumKeysPipe],
providers: [ providers: [
@@ -64,7 +65,7 @@ describe('MetadataSchemaFormComponent', () => {
const expected = Object.assign(new MetadataSchema(), { const expected = Object.assign(new MetadataSchema(), {
namespace: namespace, namespace: namespace,
prefix: prefix prefix: prefix
}); } as MetadataSchema);
beforeEach(() => { beforeEach(() => {
spyOn(component.submitForm, 'emit'); spyOn(component.submitForm, 'emit');
@@ -79,11 +80,10 @@ describe('MetadataSchemaFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit a new schema using the correct values', waitForAsync(() => { it('should emit a new schema using the correct values', async () => {
fixture.whenStable().then(() => { await fixture.whenStable();
expect(component.submitForm.emit).toHaveBeenCalledWith(expected); expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
}); });
}));
}); });
describe('with an active schema', () => { describe('with an active schema', () => {
@@ -91,7 +91,7 @@ describe('MetadataSchemaFormComponent', () => {
id: 1, id: 1,
namespace: namespace, namespace: namespace,
prefix: prefix prefix: prefix
}); } as MetadataSchema);
beforeEach(() => { beforeEach(() => {
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId)); spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId));
@@ -99,11 +99,10 @@ describe('MetadataSchemaFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should edit the existing schema using the correct values', waitForAsync(() => { it('should edit the existing schema using the correct values', async () => {
fixture.whenStable().then(() => { await fixture.whenStable();
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
}); });
}));
}); });
}); });
}); });

View File

@@ -5,7 +5,7 @@ import {
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel DynamicInputModel
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@@ -66,7 +66,7 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
/** /**
* A FormGroup that combines all inputs * A FormGroup that combines all inputs
*/ */
formGroup: FormGroup; formGroup: UntypedFormGroup;
/** /**
* An EventEmitter that's fired whenever the form is being submitted * An EventEmitter that's fired whenever the form is being submitted
@@ -77,19 +77,24 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
} }
ngOnInit() { ngOnInit() {
combineLatest( combineLatest([
this.translateService.get(`${this.messagePrefix}.name`), this.translateService.get(`${this.messagePrefix}.name`),
this.translateService.get(`${this.messagePrefix}.namespace`) this.translateService.get(`${this.messagePrefix}.namespace`)
).subscribe(([name, namespace]) => { ]).subscribe(([name, namespace]) => {
this.name = new DynamicInputModel({ this.name = new DynamicInputModel({
id: 'name', id: 'name',
label: name, label: name,
name: 'name', name: 'name',
validators: { validators: {
required: null, required: null,
pattern: '^[^ ,_]{1,32}$' pattern: '^[^. ,]*$',
maxLength: 32,
}, },
required: true, required: true,
errorMessages: {
pattern: 'error.validation.metadata.name.invalid-pattern',
maxLength: 'error.validation.metadata.name.max-length',
},
}); });
this.namespace = new DynamicInputModel({ this.namespace = new DynamicInputModel({
id: 'namespace', id: 'namespace',
@@ -97,8 +102,12 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
name: 'namespace', name: 'namespace',
validators: { validators: {
required: null, required: null,
maxLength: 256,
}, },
required: true, required: true,
errorMessages: {
maxLength: 'error.validation.metadata.namespace.max-length',
},
}); });
this.formModel = [ this.formModel = [
new DynamicFormGroupModel( new DynamicFormGroupModel(
@@ -108,13 +117,18 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
}) })
]; ];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.registryService.getActiveMetadataSchema().subscribe((schema) => { this.registryService.getActiveMetadataSchema().subscribe((schema: MetadataSchema) => {
this.formGroup.patchValue({ if (schema == null) {
metadatadataschemagroup:{ this.clearFields();
name: schema != null ? schema.prefix : '', } else {
namespace: schema != null ? schema.namespace : '' this.formGroup.patchValue({
} metadatadataschemagroup: {
}); name: schema.prefix,
namespace: schema.namespace,
},
});
this.name.disabled = true;
}
}); });
}); });
} }
@@ -132,10 +146,10 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
* When the schema has no id attached -> Create new schema * When the schema has no id attached -> Create new schema
* Emit the updated/created schema using the EventEmitter submitForm * Emit the updated/created schema using the EventEmitter submitForm
*/ */
onSubmit() { onSubmit(): void {
this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.clearMetadataSchemaRequests().subscribe();
this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe( this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe(
(schema) => { (schema: MetadataSchema) => {
const values = { const values = {
prefix: this.name.value, prefix: this.name.value,
namespace: this.namespace.value namespace: this.namespace.value
@@ -147,9 +161,9 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
} else { } else {
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, { this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, {
id: schema.id, id: schema.id,
prefix: (values.prefix ? values.prefix : schema.prefix), prefix: schema.prefix,
namespace: (values.namespace ? values.namespace : schema.namespace) namespace: values.namespace,
})).subscribe((updatedSchema) => { })).subscribe((updatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedSchema); this.submitForm.emit(updatedSchema);
}); });
} }
@@ -162,13 +176,9 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
/** /**
* Reset all input-fields to be empty * Reset all input-fields to be empty
*/ */
clearFields() { clearFields(): void {
this.formGroup.patchValue({ this.formGroup.reset('metadatadataschemagroup');
metadatadataschemagroup:{ this.name.disabled = false;
prefix: '',
namespace: ''
}
});
} }
/** /**

View File

@@ -39,14 +39,16 @@ describe('MetadataFieldFormComponent', () => {
createFormGroup: () => { createFormGroup: () => {
return { return {
patchValue: () => { patchValue: () => {
} },
reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void {
},
}; };
} }
}; };
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */ /* eslint-enable no-empty, @typescript-eslint/no-empty-function */
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [MetadataFieldFormComponent, EnumKeysPipe], declarations: [MetadataFieldFormComponent, EnumKeysPipe],
providers: [ providers: [
@@ -98,11 +100,10 @@ describe('MetadataFieldFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit a new field using the correct values', waitForAsync(() => { it('should emit a new field using the correct values', async () => {
fixture.whenStable().then(() => { await fixture.whenStable();
expect(component.submitForm.emit).toHaveBeenCalledWith(expected); expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
}); });
}));
}); });
describe('with an active field', () => { describe('with an active field', () => {
@@ -120,11 +121,10 @@ describe('MetadataFieldFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should edit the existing field using the correct values', waitForAsync(() => { it('should edit the existing field using the correct values', async () => {
fixture.whenStable().then(() => { await fixture.whenStable();
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
}); });
}));
}); });
}); });
}); });

View File

@@ -5,7 +5,7 @@ import {
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel DynamicInputModel
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@@ -82,7 +82,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
/** /**
* A FormGroup that combines all inputs * A FormGroup that combines all inputs
*/ */
formGroup: FormGroup; formGroup: UntypedFormGroup;
/** /**
* An EventEmitter that's fired whenever the form is being submitted * An EventEmitter that's fired whenever the form is being submitted
@@ -98,25 +98,39 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
* Initialize the component, setting up the necessary Models for the dynamic form * Initialize the component, setting up the necessary Models for the dynamic form
*/ */
ngOnInit() { ngOnInit() {
combineLatest( combineLatest([
this.translateService.get(`${this.messagePrefix}.element`), this.translateService.get(`${this.messagePrefix}.element`),
this.translateService.get(`${this.messagePrefix}.qualifier`), this.translateService.get(`${this.messagePrefix}.qualifier`),
this.translateService.get(`${this.messagePrefix}.scopenote`) this.translateService.get(`${this.messagePrefix}.scopenote`)
).subscribe(([element, qualifier, scopenote]) => { ]).subscribe(([element, qualifier, scopenote]) => {
this.element = new DynamicInputModel({ this.element = new DynamicInputModel({
id: 'element', id: 'element',
label: element, label: element,
name: 'element', name: 'element',
validators: { validators: {
required: null, required: null,
pattern: '^[^. ,]*$',
maxLength: 64,
}, },
required: true, required: true,
errorMessages: {
pattern: 'error.validation.metadata.element.invalid-pattern',
maxLength: 'error.validation.metadata.element.max-length',
},
}); });
this.qualifier = new DynamicInputModel({ this.qualifier = new DynamicInputModel({
id: 'qualifier', id: 'qualifier',
label: qualifier, label: qualifier,
name: 'qualifier', name: 'qualifier',
validators: {
pattern: '^[^. ,]*$',
maxLength: 64,
},
required: false, required: false,
errorMessages: {
pattern: 'error.validation.metadata.qualifier.invalid-pattern',
maxLength: 'error.validation.metadata.qualifier.max-length',
},
}); });
this.scopeNote = new DynamicInputModel({ this.scopeNote = new DynamicInputModel({
id: 'scopeNote', id: 'scopeNote',
@@ -132,14 +146,20 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
}) })
]; ];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.registryService.getActiveMetadataField().subscribe((field) => { this.registryService.getActiveMetadataField().subscribe((field: MetadataField): void => {
this.formGroup.patchValue({ if (field == null) {
metadatadatafieldgroup: { this.clearFields();
element: field != null ? field.element : '', } else {
qualifier: field != null ? field.qualifier : '', this.formGroup.patchValue({
scopeNote: field != null ? field.scopeNote : '' metadatadatafieldgroup: {
} element: field.element,
}); qualifier: field.qualifier,
scopeNote: field.scopeNote,
},
});
this.element.disabled = true;
this.qualifier.disabled = true;
}
}); });
}); });
} }
@@ -157,25 +177,24 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
* When the field has no id attached -> Create new field * When the field has no id attached -> Create new field
* Emit the updated/created field using the EventEmitter submitForm * Emit the updated/created field using the EventEmitter submitForm
*/ */
onSubmit() { onSubmit(): void {
this.registryService.getActiveMetadataField().pipe(take(1)).subscribe( this.registryService.getActiveMetadataField().pipe(take(1)).subscribe(
(field) => { (field: MetadataField) => {
const values = {
element: this.element.value,
qualifier: this.qualifier.value,
scopeNote: this.scopeNote.value
};
if (field == null) { if (field == null) {
this.registryService.createMetadataField(Object.assign(new MetadataField(), values), this.metadataSchema).subscribe((newField) => { this.registryService.createMetadataField(Object.assign(new MetadataField(), {
element: this.element.value,
qualifier: this.qualifier.value,
scopeNote: this.scopeNote.value,
}), this.metadataSchema).subscribe((newField: MetadataField) => {
this.submitForm.emit(newField); this.submitForm.emit(newField);
}); });
} else { } else {
this.registryService.updateMetadataField(Object.assign(new MetadataField(), field, { this.registryService.updateMetadataField(Object.assign(new MetadataField(), field, {
id: field.id, id: field.id,
element: (values.element ? values.element : field.element), element: field.element,
qualifier: (values.qualifier ? values.qualifier : field.qualifier), qualifier: field.qualifier,
scopeNote: (values.scopeNote ? values.scopeNote : field.scopeNote) scopeNote: this.scopeNote.value,
})).subscribe((updatedField) => { })).subscribe((updatedField: MetadataField) => {
this.submitForm.emit(updatedField); this.submitForm.emit(updatedField);
}); });
} }
@@ -188,14 +207,10 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
/** /**
* Reset all input-fields to be empty * Reset all input-fields to be empty
*/ */
clearFields() { clearFields(): void {
this.formGroup.patchValue({ this.formGroup.reset('metadatadatafieldgroup');
metadatadatafieldgroup: { this.element.disabled = false;
element: '', this.qualifier.disabled = false;
qualifier: '',
scopeNote: ''
}
});
} }
/** /**

View File

@@ -34,14 +34,14 @@
<tr *ngFor="let field of fields?.page" <tr *ngFor="let field of fields?.page"
[ngClass]="{'table-primary' : isActive(field) | async}"> [ngClass]="{'table-primary' : isActive(field) | async}">
<td> <td>
<label> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(field) | async" [checked]="isSelected(field) | async"
(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)">{{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" class="mb-0">.</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>
</tbody> </tbody>

View File

@@ -1,3 +1,8 @@
.selectable-row:hover { .selectable-row:hover {
cursor: pointer; cursor: pointer;
} }
:host ::ng-deep #metadatadatafieldgroup {
display: flex;
flex-wrap: wrap;
}

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