Merge branch 'main' into regex-error-messages

This commit is contained in:
max.nuding
2024-03-13 14:49:51 +01:00
3079 changed files with 96224 additions and 37461 deletions

View File

@@ -8,7 +8,10 @@
"eslint-plugin-deprecation", "eslint-plugin-deprecation",
"unused-imports", "unused-imports",
"eslint-plugin-lodash", "eslint-plugin-lodash",
"eslint-plugin-jsonc" "eslint-plugin-jsonc",
"eslint-plugin-rxjs",
"eslint-plugin-simple-import-sort",
"eslint-plugin-import-newlines"
], ],
"overrides": [ "overrides": [
{ {
@@ -27,17 +30,29 @@
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@angular-eslint/recommended", "plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates" "plugin:@angular-eslint/template/process-inline-templates",
"plugin:rxjs/recommended"
], ],
"rules": { "rules": {
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"max-classes-per-file": [ "max-classes-per-file": [
"error", "error",
1 1
], ],
"comma-dangle": [ "comma-dangle": [
"off", "error",
"always-multiline" "always-multiline"
], ],
"object-curly-spacing": [
"error",
"always"
],
"eol-last": [ "eol-last": [
"error", "error",
"always" "always"
@@ -104,15 +119,13 @@
"allowTernary": true "allowTernary": true
} }
], ],
"prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint) "prefer-const": "error",
"no-case-declarations": "error",
"no-extra-boolean-cast": "error",
"prefer-spread": "off", "prefer-spread": "off",
"no-underscore-dangle": "off", "no-underscore-dangle": "off",
// todo: disabled rules from eslint:recommended, consider re-enabling & fixing
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"no-useless-escape": "off", "no-useless-escape": "off",
"no-case-declarations": "off",
"no-extra-boolean-cast": "off",
"@angular-eslint/directive-selector": [ "@angular-eslint/directive-selector": [
"error", "error",
@@ -183,7 +196,7 @@
], ],
"@typescript-eslint/type-annotation-spacing": "error", "@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error", "@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable "@typescript-eslint/ban-types": "error",
"@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/restrict-plus-operands": "warn", "@typescript-eslint/restrict-plus-operands": "warn",
@@ -203,14 +216,45 @@
"deprecation/deprecation": "warn", "deprecation/deprecation": "warn",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"import/order": "off", "import/order": "off",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"import/no-deprecated": "warn", "import/no-deprecated": "warn",
"import/no-namespace": "error", "import/no-namespace": "error",
"import-newlines/enforce": [
"error",
{
"items": 1,
"semi": true,
"forceSingleLine": true
}
],
"unused-imports/no-unused-imports": "error", "unused-imports/no-unused-imports": "error",
"lodash/import-scope": [ "lodash/import-scope": [
"error", "error",
"method" "method"
] ],
"rxjs/no-nested-subscribe": "off" // todo: go over _all_ cases
}
},
{
"files": [
"*.spec.ts"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./cypress/tsconfig.json"
],
"createDefaultProgram": true
},
"rules": {
"prefer-const": "off"
} }
}, },
{ {
@@ -219,12 +263,7 @@
], ],
"extends": [ "extends": [
"plugin:@angular-eslint/template/recommended" "plugin:@angular-eslint/template/recommended"
], ]
"rules": {
// todo: re-enable & fix errors
"@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off"
}
}, },
{ {
"files": [ "files": [

View File

@@ -33,6 +33,8 @@ jobs:
#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) # Bump Node heap size (OOM in CI after upgrading to Angular 15)
NODE_OPTIONS: '--max-old-space-size=4096' NODE_OPTIONS: '--max-old-space-size=4096'
# Project name to use when running docker-compose prior to e2e tests
COMPOSE_PROJECT_NAME: 'ci'
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:
@@ -43,11 +45,11 @@ jobs:
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout codebase - name: Checkout codebase
uses: actions/checkout@v3 uses: actions/checkout@v4
# https://github.com/actions/setup-node # https://github.com/actions/setup-node
- name: Install Node.js ${{ matrix.node-version }} - name: Install Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@@ -118,7 +120,7 @@ jobs:
# https://github.com/cypress-io/github-action # https://github.com/cypress-io/github-action
# (NOTE: to run these e2e tests locally, just use 'ng e2e') # (NOTE: to run these e2e tests locally, just use 'ng e2e')
- name: Run e2e tests (integration tests) - name: Run e2e tests (integration tests)
uses: cypress-io/github-action@v5 uses: cypress-io/github-action@v6
with: with:
# Run tests in Chrome, headless mode (default) # Run tests in Chrome, headless mode (default)
browser: chrome browser: chrome
@@ -191,7 +193,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
# Download artifacts from previous 'tests' job # Download artifacts from previous 'tests' job
- name: Download coverage artifacts - name: Download coverage artifacts
@@ -203,10 +205,14 @@ jobs:
# Retry action: https://github.com/marketplace/actions/retry-action # Retry action: https://github.com/marketplace/actions/retry-action
# Codecov action: https://github.com/codecov/codecov-action # Codecov action: https://github.com/codecov/codecov-action
- name: Upload coverage to Codecov.io - name: Upload coverage to Codecov.io
uses: Wandalen/wretry.action@v1.0.36 uses: Wandalen/wretry.action@v1.3.0
with: with:
action: codecov/codecov-action@v3 action: codecov/codecov-action@v3
# Try upload 5 times max # Ensure codecov-action throws an error when it fails to upload
# This allows us to auto-restart the action if an error is thrown
with: |
fail_ci_if_error: true
# Try re-running action 5 times max
attempt_limit: 5 attempt_limit: 5
# Run again in 30 seconds # Run again in 30 seconds
attempt_delay: 30000 attempt_delay: 30000

View File

@@ -35,7 +35,7 @@ jobs:
steps: steps:
# https://github.com/actions/checkout # https://github.com/actions/checkout
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
# https://github.com/github/codeql-action # https://github.com/github/codeql-action

View File

@@ -3,6 +3,9 @@ name: Docker images
# Run this Build for all pushes to 'main' or maintenance branches, or tagged releases. # Run this Build for all pushes to 'main' or maintenance branches, or tagged releases.
# Also run for PRs to ensure PR doesn't break Docker build process # Also run for PRs to ensure PR doesn't break Docker build process
# NOTE: uses "reusable-docker-build.yml" in DSpace/DSpace to actually build each of the Docker images
# https://github.com/DSpace/DSpace/blob/main/.github/workflows/reusable-docker-build.yml
#
on: on:
push: push:
branches: branches:
@@ -15,82 +18,22 @@ 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:
############################################### #############################################################
# Build/Push the 'dspace/dspace-angular' image # Build/Push the 'dspace/dspace-angular' image
############################################### #############################################################
dspace-angular: 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 # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image
uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main
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: with:
username: ${{ secrets.DOCKER_USERNAME }} build_id: dspace-angular
password: ${{ secrets.DOCKER_ACCESS_TOKEN }} image_name: dspace/dspace-angular
dockerfile_path: ./Dockerfile
# https://github.com/docker/metadata-action secrets:
# Get Metadata for docker_build step below DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
id: meta_build
uses: docker/metadata-action@v4
with:
images: dspace/dspace-angular
tags: ${{ env.IMAGE_TAGS }}
flavor: ${{ env.TAGS_FLAVOR }}
# https://github.com/docker/build-push-action
- name: Build and push 'dspace-angular' image
id: docker_build
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
# Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build.outputs.tags }}
labels: ${{ steps.meta_build.outputs.labels }}
############################################################# #############################################################
# Build/Push the 'dspace/dspace-angular' image ('-dist' tag) # Build/Push the 'dspace/dspace-angular' image ('-dist' tag)
@@ -98,53 +41,19 @@ jobs:
dspace-angular-dist: dspace-angular-dist:
# 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 # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image
uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main
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: with:
username: ${{ secrets.DOCKER_USERNAME }} build_id: dspace-angular-dist
password: ${{ secrets.DOCKER_ACCESS_TOKEN }} image_name: dspace/dspace-angular
dockerfile_path: ./Dockerfile.dist
# 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 # 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. # tagging logic as the primary 'dspace/dspace-angular' image above.
flavor: ${{ env.TAGS_FLAVOR }} tags_flavor: suffix=-dist
suffix=-dist secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Build and push 'dspace-angular-dist' image DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
id: docker_build_dist # Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of
uses: docker/build-push-action@v4 # these sites as specified in reusable-docker-build.xml
with: REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }}
context: . REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }}
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

@@ -23,11 +23,11 @@ jobs:
if: github.event.pull_request.merged if: github.event.pull_request.merged
steps: steps:
# Checkout code # Checkout code
- uses: actions/checkout@v3 - uses: actions/checkout@v4
# Port PR to other branch (ONLY if labeled with "port to") # Port PR to other branch (ONLY if labeled with "port to")
# See https://github.com/korthout/backport-action # See https://github.com/korthout/backport-action
- name: Create backport pull requests - name: Create backport pull requests
uses: korthout/backport-action@v1 uses: korthout/backport-action@v2
with: with:
# Trigger based on a "port to [branch]" label on PR # Trigger based on a "port to [branch]" label on PR
# (This label must specify the branch name to port to) # (This label must specify the branch name to port to)

View File

@@ -21,4 +21,4 @@ jobs:
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
# See https://github.com/toshimaru/auto-author-assign # See https://github.com/toshimaru/auto-author-assign
- name: Assign PR to creator - name: Assign PR to creator
uses: toshimaru/auto-author-assign@v1.6.2 uses: toshimaru/auto-author-assign@v2.0.1

View File

@@ -131,12 +131,16 @@ submission:
# NOTE: after how many time (milliseconds) submission is saved automatically # NOTE: after how many time (milliseconds) submission is saved automatically
# eg. timer: 5 * (1000 * 60); // 5 minutes # eg. timer: 5 * (1000 * 60); // 5 minutes
timer: 0 timer: 0
# Always show the duplicate detection section if enabled, even if there are no potential duplicates detected
# (a message will be displayed to indicate no matches were found)
duplicateDetection:
alwaysShowSection: false
icons: icons:
metadata: metadata:
# NOTE: example of configuration # NOTE: example of configuration
# # NOTE: metadata name # # NOTE: metadata name
# - name: dc.author # - name: dc.author
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: fas fa-user # style: fas fa-user
- name: dc.author - name: dc.author
style: fas fa-user style: fas fa-user
@@ -147,18 +151,40 @@ submission:
confidence: confidence:
# NOTE: example of configuration # NOTE: example of configuration
# # NOTE: confidence value # # NOTE: confidence value
# - name: dc.author # - value: 600
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: fa-user # style: text-success
# icon: fa-circle-check
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
# configured to use a 'icon mode' display (mainly in edit-item page)
- value: 600 - value: 600
style: text-success style: text-success
icon: fa-circle-check
- value: 500 - value: 500
style: text-info style: text-info
icon: fa-gear
- value: 400 - value: 400
style: text-warning style: text-warning
icon: fa-circle-question
- value: 300
style: text-muted
icon: fa-thumbs-down
- value: 200
style: text-muted
icon: fa-circle-exclamation
- value: 100
style: text-muted
icon: fa-circle-stop
- value: 0
style: text-muted
icon: fa-ban
- value: -1
style: text-muted
icon: fa-circle-xmark
# default configuration # default configuration
- value: default - value: default
style: text-muted style: text-muted
icon: fa-circle-xmark
# Default Language in which the UI will be rendered if the user's browser language is not an active language # Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: en defaultLanguage: en
@@ -272,6 +298,8 @@ homePage:
# No. of communities to list per page on the home page # No. of communities to list per page on the home page
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
pageSize: 5 pageSize: 5
# Enable or disable the Discover filters on the homepage
showDiscoverFilters: false
# Item Config # Item Config
item: item:
@@ -285,8 +313,17 @@ item:
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
pageSize: 5 pageSize: 5
# Community Page Config
community:
# Search tab config
searchSection:
showSidebar: true
# Collection Page Config # Collection Page Config
collection: collection:
# Search tab config
searchSection:
showSidebar: true
edit: edit:
undoTimeout: 10000 # 10 seconds undoTimeout: 10000 # 10 seconds
@@ -386,3 +423,75 @@ vocabularies:
comcolSelectionSort: comcolSelectionSort:
sortField: 'dc.title' sortField: 'dc.title'
sortDirection: 'ASC' sortDirection: 'ASC'
# Example of fallback collection for suggestions import
# suggestion:
# - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af
# source: "openaire"
# Search settings
search:
# Settings to enable/disable or configure advanced search filters.
advancedFilters:
enabled: false
# List of filters to enable in "Advanced Search" dropdown
filter: [ 'title', 'author', 'subject', 'entityType' ]
# Notify metrics
# Configuration for Notify Admin Dashboard for metrics visualization
notifyMetrics:
# Configuration for received messages
- title: 'admin-notify-dashboard.received-ldn'
boxes:
- color: '#B8DAFF'
title: 'admin-notify-dashboard.NOTIFY.incoming.accepted'
config: 'NOTIFY.incoming.accepted'
description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description'
- color: '#D4EDDA'
title: 'admin-notify-dashboard.NOTIFY.incoming.processed'
config: 'NOTIFY.incoming.processed'
description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.incoming.failure'
config: 'NOTIFY.incoming.failure'
description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted'
config: 'NOTIFY.incoming.untrusted'
description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description'
- color: '#43515F'
title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems'
textColor: '#fff'
config: 'NOTIFY.incoming.involvedItems'
description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description'
# Configuration for outgoing messages
- title: 'admin-notify-dashboard.generated-ldn'
boxes:
- color: '#B8DAFF'
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued'
config: 'NOTIFY.outgoing.queued'
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description'
- color: '#FDEEBB'
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry'
config: 'NOTIFY.outgoing.queued_for_retry'
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.outgoing.failure'
config: 'NOTIFY.outgoing.failure'
description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description'
- color: '#43515F'
title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems'
textColor: '#fff'
config: 'NOTIFY.outgoing.involvedItems'
description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description'
- color: '#D4EDDA'
title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered'
config: 'NOTIFY.outgoing.delivered'
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'

View File

@@ -9,8 +9,9 @@ export default defineConfig({
openMode: 0, openMode: 0,
}, },
env: { env: {
// Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts) // Global DSpace environment variables used in all our Cypress e2e tests
// May be overridden in our cypress.json config file using specified environment variables. // May be modified in this config, or overridden in a variety of ways.
// See Cypress environment variable docs: https://docs.cypress.io/guides/guides/environment-variables
// Default values listed here are all valid for the Demo Entities Data set available at // 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 // 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)
@@ -21,12 +22,14 @@ export default defineConfig({
// Community/collection/publication used for view/edit tests // Community/collection/publication used for view/edit tests
DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4', DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4',
DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200', DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200',
DSPACE_TEST_ENTITY_PUBLICATION: 'e98b0f27-5c19-49a0-960d-eb6ad5287067', DSPACE_TEST_ENTITY_PUBLICATION: '6160810f-1e53-40db-81ef-f6621a727398',
// Search term (should return results) used in search tests // Search term (should return results) used in search tests
DSPACE_TEST_SEARCH_TERM: 'test', DSPACE_TEST_SEARCH_TERM: 'test',
// Collection used for submission tests // Main Collection used for submission tests. Should be able to accept normal Item objects
DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection', DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144', DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
// Collection used for Person entity submission tests. MUST be configured with EntityType=Person.
DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People',
// Account used to test basic submission process // Account used to test basic submission process
DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',

View File

@@ -0,0 +1,28 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
describe('Admin Sidebar', () => {
beforeEach(() => {
// Must login as an Admin for sidebar to appear
cy.visit('/login');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should be pinnable and pass accessibility tests', () => {
// Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click();
// Click on every expandable section to open all menus
cy.get('ds-expandable-admin-sidebar-section').click({multiple: true});
// Analyze <ds-admin-sidebar> for accessibility
testA11y('ds-admin-sidebar',
{
rules: {
// Currently all expandable sections have nested interactive elements
// See https://github.com/DSpace/dspace-angular/issues/2178
'nested-interactive': { enabled: false },
}
} as Options);
});
});

View File

@@ -1,10 +1,9 @@
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/'.concat(TEST_ENTITY_PUBLICATION)); cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_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

@@ -5,9 +5,9 @@ describe('Browse By Author', () => {
cy.visit('/browse/author'); cy.visit('/browse/author');
// Wait for <ds-browse-by-metadata-page> to be visible // Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible'); cy.get('ds-browse-by-metadata').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility // Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page'); testA11y('ds-browse-by-metadata');
}); });
}); });

View File

@@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => {
cy.visit('/browse/dateissued'); cy.visit('/browse/dateissued');
// Wait for <ds-browse-by-date-page> to be visible // Wait for <ds-browse-by-date-page> to be visible
cy.get('ds-browse-by-date-page').should('be.visible'); cy.get('ds-browse-by-date').should('be.visible');
// Analyze <ds-browse-by-date-page> for accessibility // Analyze <ds-browse-by-date-page> for accessibility
testA11y('ds-browse-by-date-page'); testA11y('ds-browse-by-date');
}); });
}); });

View File

@@ -5,9 +5,9 @@ describe('Browse By Subject', () => {
cy.visit('/browse/subject'); cy.visit('/browse/subject');
// Wait for <ds-browse-by-metadata-page> to be visible // Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible'); cy.get('ds-browse-by-metadata').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility // Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page'); testA11y('ds-browse-by-metadata');
}); });
}); });

View File

@@ -5,9 +5,9 @@ describe('Browse By Title', () => {
cy.visit('/browse/title'); cy.visit('/browse/title');
// Wait for <ds-browse-by-title-page> to be visible // Wait for <ds-browse-by-title-page> to be visible
cy.get('ds-browse-by-title-page').should('be.visible'); cy.get('ds-browse-by-title').should('be.visible');
// Analyze <ds-browse-by-title-page> for accessibility // Analyze <ds-browse-by-title-page> for accessibility
testA11y('ds-browse-by-title-page'); testA11y('ds-browse-by-title');
}); });
}); });

View File

@@ -0,0 +1,128 @@
import { testA11y } from 'cypress/support/utils';
const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit');
beforeEach(() => {
// All tests start with visiting the Edit Collection Page
cy.visit(COLLECTION_EDIT_PAGE);
// This page is restricted, so we will be shown the login form. Fill it out & submit.
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
describe('Edit Collection > Edit Metadata tab', () => {
it('should pass accessibility tests', () => {
// <ds-edit-collection> tag must be loaded
cy.get('ds-edit-collection').should('be.visible');
// Analyze <ds-edit-collection> for accessibility issues
testA11y('ds-edit-collection');
});
});
describe('Edit Collection > Assign Roles tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="roles"]').click();
// <ds-collection-roles> tag must be loaded
cy.get('ds-collection-roles').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-collection-roles');
});
});
describe('Edit Collection > Content Source tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="source"]').click();
// <ds-collection-source> tag must be loaded
cy.get('ds-collection-source').should('be.visible');
// Check the external source checkbox (to display all fields on the page)
cy.get('#externalSourceCheck').check();
// Wait for the source controls to appear
// cy.get('ds-collection-source-controls').should('be.visible');
// Analyze entire page for accessibility issues
testA11y('ds-collection-source');
});
});
describe('Edit Collection > Curate tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="curate"]').click();
// <ds-collection-curate> tag must be loaded
cy.get('ds-collection-curate').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-collection-curate');
});
});
describe('Edit Collection > Access Control tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="access-control"]').click();
// <ds-collection-access-control> tag must be loaded
cy.get('ds-collection-access-control').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-collection-access-control');
});
});
describe('Edit Collection > Authorizations tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="authorizations"]').click();
// <ds-collection-authorizations> tag must be loaded
cy.get('ds-collection-authorizations').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-collection-authorizations');
});
});
describe('Edit Collection > Item Mapper tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="mapper"]').click();
// <ds-collection-item-mapper> tag must be loaded
cy.get('ds-collection-item-mapper').should('be.visible');
// Analyze entire page for accessibility issues
testA11y('ds-collection-item-mapper');
// Click on the "Map new Items" tab
cy.get('li[data-test="mapTab"] a').click();
// Make sure search form is now visible
cy.get('ds-search-form').should('be.visible');
// Analyze entire page (again) for accessibility issues
testA11y('ds-collection-item-mapper');
});
});
describe('Edit Collection > Delete page', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="delete-button"]').click();
// <ds-delete-collection> tag must be loaded
cy.get('ds-delete-collection').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-delete-collection');
});
});

View File

@@ -1,10 +1,9 @@
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/'.concat(TEST_COLLECTION)); cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
// <ds-collection-page> tag must be loaded // <ds-collection-page> tag must be loaded
cy.get('ds-collection-page').should('be.visible'); cy.get('ds-collection-page').should('be.visible');

View File

@@ -1,12 +1,12 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e'; import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Collection Statistics Page', () => { describe('Collection Statistics Page', () => {
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION); const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'));
it('should load if you click on "Statistics" from a Collection page', () => { it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/'.concat(TEST_COLLECTION)); cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
}); });
@@ -18,7 +18,7 @@ describe('Collection Statistics Page', () => {
it('should contain a "Total visits per month" section', () => { it('should contain a "Total visits per month" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE); cy.visit(COLLECTIONSTATISTICSPAGE);
// Check just for existence because this table is empty in CI environment as it's historical data // 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'); cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {

View File

@@ -0,0 +1,86 @@
import { testA11y } from 'cypress/support/utils';
const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit');
beforeEach(() => {
// All tests start with visiting the Edit Community Page
cy.visit(COMMUNITY_EDIT_PAGE);
// This page is restricted, so we will be shown the login form. Fill it out & submit.
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
describe('Edit Community > Edit Metadata tab', () => {
it('should pass accessibility tests', () => {
// <ds-edit-community> tag must be loaded
cy.get('ds-edit-community').should('be.visible');
// Analyze <ds-edit-community> for accessibility issues
testA11y('ds-edit-community');
});
});
describe('Edit Community > Assign Roles tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="roles"]').click();
// <ds-community-roles> tag must be loaded
cy.get('ds-community-roles').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-community-roles');
});
});
describe('Edit Community > Curate tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="curate"]').click();
// <ds-community-curate> tag must be loaded
cy.get('ds-community-curate').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-community-curate');
});
});
describe('Edit Community > Access Control tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="access-control"]').click();
// <ds-community-access-control> tag must be loaded
cy.get('ds-community-access-control').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-community-access-control');
});
});
describe('Edit Community > Authorizations tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="authorizations"]').click();
// <ds-community-authorizations> tag must be loaded
cy.get('ds-community-authorizations').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-community-authorizations');
});
});
describe('Edit Community > Delete page', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="delete-button"]').click();
// <ds-delete-community> tag must be loaded
cy.get('ds-delete-community').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-delete-community');
});
});

View File

@@ -1,15 +1,14 @@
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/'.concat(TEST_COMMUNITY)); cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
// <ds-community-page> tag must be loaded // <ds-community-page> tag must be loaded
cy.get('ds-community-page').should('be.visible'); 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

@@ -1,12 +1,12 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e'; import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Community Statistics Page', () => { describe('Community Statistics Page', () => {
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY); const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'));
it('should load if you click on "Statistics" from a Community page', () => { it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/'.concat(TEST_COMMUNITY)); cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
}); });
@@ -18,7 +18,7 @@ describe('Community Statistics Page', () => {
it('should contain a "Total visits per month" section', () => { it('should contain a "Total visits per month" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE); cy.visit(COMMUNITYSTATISTICSPAGE);
// Check just for existence because this table is empty in CI environment as it's historical data // 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'); cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {

View File

@@ -8,11 +8,6 @@ describe('Header', () => {
cy.get('ds-header').should('be.visible'); cy.get('ds-header').should('be.visible');
// Analyze <ds-header> for accessibility // Analyze <ds-header> for accessibility
testA11y({ testA11y('ds-header');
include: ['ds-header'],
exclude: [
['#search-navbar-container'] // search in navbar has duplicative ID. Will be fixed in #1174
],
});
}); });
}); });

View File

@@ -1,18 +1,18 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
import '../support/commands'; import '../support/commands';
describe('Site Statistics Page', () => { describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => { it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/'); cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', '/statistics'); cy.location('pathname').should('eq', '/statistics');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
// generate 2 view events on an Item's page // generate 2 view events on an Item's page
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
cy.visit('/statistics'); cy.visit('/statistics');

135
cypress/e2e/item-edit.cy.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit');
beforeEach(() => {
// All tests start with visiting the Edit Item Page
cy.visit(ITEM_EDIT_PAGE);
// This page is restricted, so we will be shown the login form. Fill it out & submit.
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
describe('Edit Item > Edit Metadata tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="metadata"]').click();
// <ds-edit-item-page> tag must be loaded
cy.get('ds-edit-item-page').should('be.visible');
// Analyze <ds-edit-item-page> for accessibility issues
testA11y('ds-edit-item-page');
});
});
describe('Edit Item > Status tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="status"]').click();
// <ds-item-status> tag must be loaded
cy.get('ds-item-status').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-status');
});
});
describe('Edit Item > Bitstreams tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="bitstreams"]').click();
// <ds-item-bitstreams> tag must be loaded
cy.get('ds-item-bitstreams').should('be.visible');
// Table of item bitstreams must also be loaded
cy.get('div.item-bitstreams').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-bitstreams',
{
rules: {
// Currently Bitstreams page loads a pagination component per Bundle
// and they all use the same 'id="p-dad"'.
'duplicate-id': { enabled: false },
}
} as Options
);
});
});
describe('Edit Item > Curate tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="curate"]').click();
// <ds-item-curate> tag must be loaded
cy.get('ds-item-curate').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-curate');
});
});
describe('Edit Item > Relationships tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="relationships"]').click();
// <ds-item-relationships> tag must be loaded
cy.get('ds-item-relationships').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-relationships');
});
});
describe('Edit Item > Version History tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="versionhistory"]').click();
// <ds-item-version-history> tag must be loaded
cy.get('ds-item-version-history').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-version-history');
});
});
describe('Edit Item > Access Control tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="access-control"]').click();
// <ds-item-access-control> tag must be loaded
cy.get('ds-item-access-control').should('be.visible');
// Analyze for accessibility issues
testA11y('ds-item-access-control');
});
});
describe('Edit Item > Collection Mapper tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="mapper"]').click();
// <ds-item-collection-mapper> tag must be loaded
cy.get('ds-item-collection-mapper').should('be.visible');
// Analyze entire page for accessibility issues
testA11y('ds-item-collection-mapper');
// Click on the "Map new collections" tab
cy.get('li[data-test="mapTab"] a').click();
// Make sure search form is now visible
cy.get('ds-search-form').should('be.visible');
// Analyze entire page (again) for accessibility issues
testA11y('ds-item-collection-mapper');
});
});

View File

@@ -1,9 +1,8 @@
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/'.concat(TEST_ENTITY_PUBLICATION); const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_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', () => {

View File

@@ -1,12 +1,12 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { REGEX_MATCH_NON_EMPTY_TEXT } 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/'.concat(TEST_ENTITY_PUBLICATION); const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_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/'.concat(TEST_ENTITY_PUBLICATION)); cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
}); });
@@ -24,7 +24,7 @@ describe('Item Statistics Page', () => {
it('should contain a "Total visits per month" section', () => { it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE); cy.visit(ITEMSTATISTICSPAGE);
// Check just for existence because this table is empty in CI environment as it's historical data // 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'); cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {

View File

@@ -1,34 +1,33 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
const page = { const page = {
openLoginMenu() { openLoginMenu() {
// Click the "Log In" dropdown menu in header // Click the "Log In" dropdown menu in header
cy.get('ds-themed-navbar [data-test="login-menu"]').click(); cy.get('ds-themed-header [data-test="login-menu"]').click();
}, },
openUserMenu() { openUserMenu() {
// Once logged in, click the User menu in header // Once logged in, click the User menu in header
cy.get('ds-themed-navbar [data-test="user-menu"]').click(); cy.get('ds-themed-header [data-test="user-menu"]').click();
}, },
submitLoginAndPasswordByPressingButton(email, password) { submitLoginAndPasswordByPressingButton(email, password) {
// Enter email // Enter email
cy.get('ds-themed-navbar [data-test="email"]').type(email); cy.get('ds-themed-header [data-test="email"]').type(email);
// Enter password // Enter password
cy.get('ds-themed-navbar [data-test="password"]').type(password); cy.get('ds-themed-header [data-test="password"]').type(password);
// Click login button // Click login button
cy.get('ds-themed-navbar [data-test="login-button"]').click(); cy.get('ds-themed-header [data-test="login-button"]').click();
}, },
submitLoginAndPasswordByPressingEnter(email, password) { submitLoginAndPasswordByPressingEnter(email, password) {
// In opened Login modal, fill out email & password, then click Enter // In opened Login modal, fill out email & password, then click Enter
cy.get('ds-themed-navbar [data-test="email"]').type(email); cy.get('ds-themed-header [data-test="email"]').type(email);
cy.get('ds-themed-navbar [data-test="password"]').type(password); cy.get('ds-themed-header [data-test="password"]').type(password);
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); cy.get('ds-themed-header [data-test="password"]').type('{enter}');
}, },
submitLogoutByPressingButton() { submitLogoutByPressingButton() {
// This is the POST command that will actually log us out // This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout'); cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button // Click logout button
cy.get('ds-themed-navbar [data-test="logout-button"]').click(); cy.get('ds-themed-header [data-test="logout-button"]').click();
// Wait until above POST command responds before continuing // Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes) // (This ensures next action waits until logout completes)
cy.wait('@logout'); cy.wait('@logout');
@@ -37,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/'.concat(TEST_ENTITY_PUBLICATION); const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
cy.visit(ENTITYPAGE); cy.visit(ENTITYPAGE);
// Login menu should exist // Login menu should exist
@@ -47,7 +46,7 @@ describe('Login Modal', () => {
page.openLoginMenu(); page.openLoginMenu();
cy.get('.form-login').should('be.visible'); cy.get('.form-login').should('be.visible');
page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('ds-log-in').should('not.exist'); cy.get('ds-log-in').should('not.exist');
// Verify we are still on the same page // Verify we are still on the same page
@@ -67,7 +66,7 @@ describe('Login Modal', () => {
cy.get('.form-login').should('be.visible'); cy.get('.form-login').should('be.visible');
// Login, and the <ds-log-in> tag should no longer exist // Login, and the <ds-log-in> tag should no longer exist
page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('.form-login').should('not.exist'); cy.get('.form-login').should('not.exist');
// Verify we are still on homepage // Verify we are still on homepage
@@ -81,7 +80,7 @@ describe('Login Modal', () => {
it('should support logout', () => { it('should support logout', () => {
// First authenticate & access homepage // First authenticate & access homepage
cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.visit('/'); cy.visit('/');
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
@@ -103,12 +102,15 @@ describe('Login Modal', () => {
page.openLoginMenu(); page.openLoginMenu();
// Registration link should be visible // Registration link should be visible
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); cy.get('ds-themed-header [data-test="register"]').should('be.visible');
// Click registration link & you should go to registration page // Click registration link & you should go to registration page
cy.get('ds-themed-navbar [data-test="register"]').click(); cy.get('ds-themed-header [data-test="register"]').click();
cy.location('pathname').should('eq', '/register'); cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist'); cy.get('ds-register-email').should('exist');
// Test accessibility of this page
testA11y('ds-register-email');
}); });
it('should allow forgot password', () => { it('should allow forgot password', () => {
@@ -117,22 +119,32 @@ describe('Login Modal', () => {
page.openLoginMenu(); page.openLoginMenu();
// Forgot password link should be visible // Forgot password link should be visible
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); cy.get('ds-themed-header [data-test="forgot"]').should('be.visible');
// Click link & you should go to Forgot Password page // Click link & you should go to Forgot Password page
cy.get('ds-themed-navbar [data-test="forgot"]').click(); cy.get('ds-themed-header [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot'); cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist'); cy.get('ds-forgot-email').should('exist');
// Test accessibility of this page
testA11y('ds-forgot-email');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests in menus', () => {
cy.visit('/'); cy.visit('/');
// Open login menu & verify accessibility
page.openLoginMenu(); page.openLoginMenu();
cy.get('ds-log-in').should('exist'); cy.get('ds-log-in').should('exist');
// Analyze <ds-log-in> for accessibility issues
testA11y('ds-log-in'); testA11y('ds-log-in');
// Now login
page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('ds-log-in').should('not.exist');
// Open user menu, verify user menu accesibility
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
testA11y('ds-user-menu');
}); });
}); });

View File

@@ -1,5 +1,3 @@
import { Options } from 'cypress-axe';
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', () => {
@@ -7,7 +5,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// 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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
cy.get('ds-my-dspace-page').should('be.visible'); cy.get('ds-my-dspace-page').should('be.visible');
@@ -26,7 +24,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// 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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
cy.get('ds-my-dspace-page').should('be.visible'); cy.get('ds-my-dspace-page').should('be.visible');
@@ -35,16 +33,8 @@ describe('My DSpace page', () => {
cy.get('ds-object-detail').should('be.visible'); cy.get('ds-object-detail').should('be.visible');
// Analyze <ds-search-page> for accessibility issues // Analyze <ds-my-dspace-page> for accessibility issues
testA11y('ds-my-dspace-page', testA11y('ds-my-dspace-page');
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
}); });
// NOTE: Deleting existing submissions is exercised by submission.spec.ts // NOTE: Deleting existing submissions is exercised by submission.spec.ts
@@ -52,7 +42,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// 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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
// Open the New Submission dropdown // Open the New Submission dropdown
cy.get('button[data-test="submission-dropdown"]').click(); cy.get('button[data-test="submission-dropdown"]').click();
@@ -63,10 +53,10 @@ describe('My DSpace page', () => {
cy.get('ds-create-item-parent-selector').should('be.visible'); cy.get('ds-create-item-parent-selector').should('be.visible');
// Type in a known Collection name in the search box // Type in a known Collection name in the search box
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_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="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click(); cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_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');
@@ -75,7 +65,7 @@ describe('My DSpace page', () => {
cy.get('ds-submission-edit').should('be.visible'); cy.get('ds-submission-edit').should('be.visible');
// A Collection menu button should exist & its value should be the selected collection // A Collection menu button should exist & its value should be the selected collection
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME'));
// Now that we've created a submission, we'll test that we can go back and Edit it. // Now that we've created a submission, we'll test that we can go back and Edit it.
// Get our Submission URL, to parse out the ID of this new submission // Get our Submission URL, to parse out the ID of this new submission
@@ -124,7 +114,7 @@ describe('My DSpace page', () => {
cy.visit('/mydspace'); cy.visit('/mydspace');
// 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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
// Open the New Import dropdown // Open the New Import dropdown
cy.get('button[data-test="import-dropdown"]').click(); cy.get('button[data-test="import-dropdown"]').click();
@@ -136,6 +126,9 @@ describe('My DSpace page', () => {
// The external import searchbox should be visible // The external import searchbox should be visible
cy.get('ds-submission-import-external-searchbar').should('be.visible'); cy.get('ds-submission-import-external-searchbar').should('be.visible');
// Test for accessibility issues
testA11y('ds-submission-import-external');
}); });
}); });

View File

@@ -1,23 +1,21 @@
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
const page = { const page = {
fillOutQueryInNavBar(query) { fillOutQueryInNavBar(query) {
// Click the magnifying glass // Click the magnifying glass
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); cy.get('ds-themed-header [data-test="header-search-icon"]').click();
// Fill out a query in input that appears // Fill out a query in input that appears
cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); cy.get('ds-themed-header [data-test="header-search-box"]').type(query);
}, },
submitQueryByPressingEnter() { submitQueryByPressingEnter() {
cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}');
}, },
submitQueryByPressingIcon() { submitQueryByPressingIcon() {
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); cy.get('ds-themed-header [data-test="header-search-icon"]').click();
} }
}; };
describe('Search from Navigation Bar', () => { describe('Search from Navigation Bar', () => {
// NOTE: these tests currently assume this query will return results! // NOTE: these tests currently assume this query will return results!
const query = TEST_SEARCH_TERM; const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');
it('should go to search page with correct query if submitted (from home)', () => { it('should go to search page with correct query if submitted (from home)', () => {
cy.visit('/'); cy.visit('/');

View File

@@ -1,8 +1,10 @@
import { Options } from 'cypress-axe'; import { Options } from 'cypress-axe';
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', () => {
// NOTE: these tests currently assume this query will return results!
const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');
it('should redirect to the correct url when query was set and submit button was triggered', () => { it('should redirect to the correct url when query was set and submit button was triggered', () => {
const queryString = 'Another interesting query string'; const queryString = 'Another interesting query string';
cy.visit('/search'); cy.visit('/search');
@@ -13,8 +15,8 @@ describe('Search Page', () => {
}); });
it('should load results and pass accessibility tests', () => { it('should load results and pass accessibility tests', () => {
cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); cy.visit('/search?query='.concat(query));
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); cy.get('[data-test="search-box"]').should('have.value', query);
// <ds-search-page> tag must be loaded // <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('be.visible'); cy.get('ds-search-page').should('be.visible');
@@ -31,7 +33,7 @@ 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='.concat(TEST_SEARCH_TERM)); cy.visit('/search?query='.concat(query));
// 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();
@@ -46,9 +48,8 @@ describe('Search Page', () => {
testA11y('ds-search-page', testA11y('ds-search-page',
{ {
rules: { rules: {
// Search filters fail these two "moderate" impact rules // Card titles fail this test currently
'heading-order': { enabled: false }, 'heading-order': { enabled: false }
'landmark-unique': { enabled: false }
} }
} as Options } as Options
); );

View File

@@ -1,14 +1,16 @@
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils';
//import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e';
import { Options } from 'cypress-axe';
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 Item 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='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
// Should redirect to /workspaceitems, as we've started a new submission // Should redirect to /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems'); cy.url().should('include', '/workspaceitems');
@@ -17,7 +19,7 @@ describe('New Submission page', () => {
cy.get('ds-submission-edit').should('be.visible'); cy.get('ds-submission-edit').should('be.visible');
// A Collection menu button should exist & it's value should be the selected collection // A Collection menu button should exist & it's value should be the selected collection
cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME'));
// 4 sections should be visible by default // 4 sections should be visible by default
cy.get('div#section_traditionalpageone').should('be.visible'); cy.get('div#section_traditionalpageone').should('be.visible');
@@ -25,6 +27,25 @@ describe('New Submission page', () => {
cy.get('div#section_upload').should('be.visible'); cy.get('div#section_upload').should('be.visible');
cy.get('div#section_license').should('be.visible'); cy.get('div#section_license').should('be.visible');
// Test entire page for accessibility
testA11y('ds-submission-edit',
{
rules: {
// Author & Subject fields have invalid "aria-multiline" attrs.
// See https://github.com/DSpace/dspace-angular/issues/1272
'aria-allowed-attr': { enabled: false },
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
// All select boxes fail to have a name / aria-label.
// This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216
'select-name': { enabled: false },
}
} as Options
);
// Discard button should work // Discard button should work
// Clicking it will display a confirmation, which we will confirm with another click // Clicking it will display a confirmation, which we will confirm with another click
cy.get('button#discard').click(); cy.get('button#discard').click();
@@ -33,10 +54,10 @@ 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='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
// Attempt an immediate deposit without filling out any fields // Attempt an immediate deposit without filling out any fields
cy.get('button#deposit').click(); cy.get('button#deposit').click();
@@ -93,10 +114,10 @@ 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='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_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(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD'));
// Fill out all required fields (Title, Date) // Fill out all required fields (Title, Date)
cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests');
@@ -131,4 +152,76 @@ describe('New Submission page', () => {
cy.get('ds-notification div.alert-success').should('be.visible'); cy.get('ds-notification div.alert-success').should('be.visible');
}); });
it('is possible to submit a new "Person" and that form passes accessibility', () => {
// To submit a different entity type, we'll start from MyDSpace
cy.visit('/mydspace');
// This page is restricted, so we will be shown the login form. Fill it out & submit.
// NOTE: At this time, we MUST login as admin to submit Person objects
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
// Open the New Submission dropdown
cy.get('button[data-test="submission-dropdown"]').click();
// Click on the "Person" type in that dropdown
cy.get('#entityControlsDropdownMenu button[title="Person"]').click();
// This should display the <ds-create-item-parent-selector> (popup window)
cy.get('ds-create-item-parent-selector').should('be.visible');
// Type in a known Collection name in the search box
cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME'));
// Click on the button matching that known Collection name
cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click();
// New URL should include /workspaceitems, as we've started a new submission
cy.url().should('include', '/workspaceitems');
// The Submission edit form tag should be visible
cy.get('ds-submission-edit').should('be.visible');
// A Collection menu button should exist & its value should be the selected collection
cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME'));
// 3 sections should be visible by default
cy.get('div#section_personStep').should('be.visible');
cy.get('div#section_upload').should('be.visible');
cy.get('div#section_license').should('be.visible');
// Test entire page for accessibility
testA11y('ds-submission-edit',
{
rules: {
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
}
} as Options
);
// Click the lookup button next to "Publication" field
cy.get('button[data-test="lookup-button"]').click();
// A popup modal window should be visible
cy.get('ds-dynamic-lookup-relation-modal').should('be.visible');
// Popup modal should also pass accessibility tests
//testA11y('ds-dynamic-lookup-relation-modal');
testA11y({
include: ['ds-dynamic-lookup-relation-modal'],
exclude: [
['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap
],
});
// Close popup window
cy.get('ds-dynamic-lookup-relation-modal button.close').click();
// Back on the form, click the discard button to remove new submission
// Clicking it will display a confirmation, which we will confirm with another click
cy.get('button#discard').click();
cy.get('button#discard_submit').click();
});
}); });

View File

@@ -1,5 +1,11 @@
const fs = require('fs'); const fs = require('fs');
// These two global variables are used to store information about the REST API used
// by these e2e tests. They are filled out prior to running any tests in the before()
// method of e2e.ts. They can then be accessed by any tests via the getters below.
let REST_BASE_URL: string;
let REST_DOMAIN: string;
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api // For more info, visit https://on.cypress.io/plugins-api
module.exports = (on, config) => { module.exports = (on, config) => {
@@ -30,6 +36,24 @@ module.exports = (on, config) => {
} }
return null; return null;
},
// Save value of REST Base URL, looked up before all tests.
// This allows other tests to use it easily via getRestBaseURL() below.
saveRestBaseURL(url: string) {
return (REST_BASE_URL = url);
},
// Retrieve currently saved value of REST Base URL
getRestBaseURL() {
return REST_BASE_URL ;
},
// Save value of REST Domain, looked up before all tests.
// This allows other tests to use it easily via getRestBaseDomain() below.
saveRestBaseDomain(domain: string) {
return (REST_DOMAIN = domain);
},
// Retrieve currently saved value of REST Domain
getRestBaseDomain() {
return REST_DOMAIN ;
} }
}); });
}; };

View File

@@ -5,11 +5,7 @@
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 { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
import { v4 as uuidv4 } from 'uuid';
// 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
@@ -41,6 +37,13 @@ declare global {
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community") * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/ */
generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
/**
* Create a new CSRF token and add to required Cookie. CSRF Token is returned
* in chainable in order to allow it to be sent also in required CSRF header.
* @returns Chainable reference to allow CSRF token to also be sent in header.
*/
createCSRFCookie(): Chainable<any>;
} }
} }
} }
@@ -54,35 +57,10 @@ declare global {
* @param password password to login as * @param password password to login as
*/ */
function login(email: string, password: string): void { function login(email: string, password: string): void {
// Cypress doesn't have access to the running application in Node.js. // Create a fake CSRF cookie/token to use in POST
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI. cy.createCSRFCookie().then((csrfToken: string) => {
// Instead, we'll read our running application's config.json, which contains the configs & // get our REST API's base URL, also needed for POST
// is regenerated at runtime each time the Angular UI application starts up. cy.task('getRestBaseURL').then((baseRestUrl: string) => {
cy.task('readUIConfig').then((str: string) => {
// Parse config into a JSON object
const config = JSON.parse(str);
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
//console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
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 = 'fakeLoginCSRFToken';
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
// Now, send login POST request including that CSRF token // Now, send login POST request including that CSRF token
cy.request({ cy.request({
method: 'POST', method: 'POST',
@@ -104,9 +82,7 @@ function login(email: string, password: string): void {
// This ensures the UI will recognize we are logged in on next "visit()" // This ensures the UI will recognize we are logged in on next "visit()"
cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); 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')
@@ -141,34 +117,10 @@ Cypress.Commands.add('loginViaForm', loginViaForm);
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community") * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/ */
function generateViewEvent(uuid: string, dsoType: string): void { function generateViewEvent(uuid: string, dsoType: string): void {
// Cypress doesn't have access to the running application in Node.js. // Create a fake CSRF cookie/token to use in POST
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI. cy.createCSRFCookie().then((csrfToken: string) => {
// Instead, we'll read our running application's config.json, which contains the configs & // get our REST API's base URL, also needed for POST
// is regenerated at runtime each time the Angular UI application starts up. cy.task('getRestBaseURL').then((baseRestUrl: string) => {
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 // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
cy.request({ cy.request({
method: 'POST', method: 'POST',
@@ -186,11 +138,32 @@ function generateViewEvent(uuid: string, dsoType: string): void {
// We expect a 201 (which means statistics event was created) // We expect a 201 (which means statistics event was created)
expect(resp.status).to.eq(201); 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') // Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
Cypress.Commands.add('generateViewEvent', generateViewEvent); Cypress.Commands.add('generateViewEvent', generateViewEvent);
/**
* Can be used by tests to generate a random XSRF/CSRF token and save it to
* the required XSRF/CSRF cookie for usage when sending POST requests or similar.
* The generated CSRF token is returned in a Chainable to allow it to be also sent
* in the CSRF HTTP Header.
* @returns a Cypress Chainable which can be used to get the generated CSRF Token
*/
function createCSRFCookie(): Cypress.Chainable {
// Generate a new token which is a random UUID
const csrfToken: string = uuidv4();
// Save it to our required cookie
cy.task('getRestBaseDomain').then((baseDomain: string) => {
// Create a fake CSRF Token. Set it in the required server-side cookie
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
});
// return the generated token wrapped in a chainable
return cy.wrap(csrfToken);
}
// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie')
Cypress.Commands.add('createCSRFCookie', createCSRFCookie);

View File

@@ -19,45 +19,53 @@ import './commands';
// Import Cypress Axe tools for all tests // Import Cypress Axe tools for all tests
// https://github.com/component-driven/cypress-axe // https://github.com/component-driven/cypress-axe
import 'cypress-axe'; import 'cypress-axe';
import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants';
// Runs once before all tests
before(() => {
// 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 URL of our REST API & save to global variable via task
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;
}
cy.task('saveRestBaseURL', baseRestUrl);
// Find domain of our REST API & save to global variable via task.
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;
}
cy.task('saveRestBaseDomain', baseDomain);
});
});
// Runs once before the first test in each "block" // Runs once before the first test in each "block"
beforeEach(() => { beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
// This just ensures it doesn't get in the way of matching other objects in the page. // This just ensures it doesn't get in the way of matching other objects in the page.
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
// Remove any CSRF cookies saved from prior tests
cy.clearCookie(DSPACE_XSRF_COOKIE);
}); });
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. // NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. // from the Angular UI's config.json. See 'before()' above.
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
/*afterEach(() => { const FALLBACK_TEST_REST_DOMAIN = 'localhost';
cy.window().then((win) => {
win.location.href = 'about:blank';
});
});*/
// Global constants used in tests
// 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
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
// Community/collection/publication used for view/edit tests
export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200';
export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4';
export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
// Search term (should return results) used in search tests
export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test';
// Collection used for submission tests
export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection';
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
// USEFUL REGEX for testing // USEFUL REGEX for testing

View File

@@ -14,13 +14,8 @@
# Therefore, it should be kept in sync with that file # Therefore, it should be kept in sync with that file
version: "3.7" version: "3.7"
networks:
dspacenet:
services: services:
dspace-cli: dspace-cli:
networks:
dspacenet: {}
environment: environment:
# This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
- LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz

View File

@@ -13,7 +13,13 @@
# #
# Therefore, it should be kept in sync with that file # Therefore, it should be kept in sync with that file
version: "3.7" version: "3.7"
networks:
# Default to using network named 'dspacenet' from docker-compose-rest.yml.
# Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet")
# If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in)
default:
name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet
external: true
services: services:
dspace-cli: dspace-cli:
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
@@ -30,16 +36,12 @@ services:
# 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
volumes: volumes:
- "assetstore:/dspace/assetstore" # Keep DSpace assetstore directory between reboots
- assetstore:/dspace/assetstore
entrypoint: /dspace/bin/dspace entrypoint: /dspace/bin/dspace
command: help command: help
networks:
- dspacenet
tty: true tty: true
stdin_open: true stdin_open: true
volumes: volumes:
assetstore: assetstore:
networks:
dspacenet:

View File

@@ -33,11 +33,11 @@ services:
# Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # 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. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
solr__D__statistics__P__autoCommit: 'false' solr__D__statistics__P__autoCommit: 'false'
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on: depends_on:
- dspacedb - dspacedb
image: dspace/dspace:latest-test
networks: networks:
dspacenet: - dspacenet
ports: ports:
- published: 8080 - published: 8080
target: 8080 target: 8080
@@ -45,8 +45,6 @@ services:
tty: true tty: true
volumes: volumes:
- assetstore:/dspace/assetstore - assetstore:/dspace/assetstore
# Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below)
- solr_configs:/dspace/solr
# Ensure that the database is ready BEFORE starting tomcat # Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
# 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any)
@@ -70,21 +68,18 @@ services:
PGDATA: /pgdata PGDATA: /pgdata
image: dspace/dspace-postgres-pgcrypto:loadsql image: dspace/dspace-postgres-pgcrypto:loadsql
networks: networks:
dspacenet: - dspacenet
stdin_open: true stdin_open: true
tty: true tty: true
volumes: volumes:
# Keep Postgres data directory between reboots
- pgdata:/pgdata - pgdata:/pgdata
# 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
depends_on:
- dspace
networks: networks:
dspacenet: - dspacenet
ports: ports:
- published: 8983 - published: 8983
target: 8983 target: 8983
@@ -92,9 +87,6 @@ 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 configsets (see above), then start Solr # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr
@@ -103,14 +95,18 @@ services:
- '-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
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai cp -r /opt/solr/server/solr/configsets/authority/* authority
precreate-core search /opt/solr/server/solr/configsets/dspace/search precreate-core oai /opt/solr/server/solr/configsets/oai
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics cp -r /opt/solr/server/solr/configsets/oai/* oai
precreate-core search /opt/solr/server/solr/configsets/search
cp -r /opt/solr/server/solr/configsets/search/* search
precreate-core statistics /opt/solr/server/solr/configsets/statistics
cp -r /opt/solr/server/solr/configsets/statistics/* statistics
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
exec solr -f exec solr -f
volumes: volumes:
assetstore: assetstore:
pgdata: pgdata:
solr_data: solr_data:
# Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above)
solr_configs:

View File

@@ -43,7 +43,7 @@ services:
depends_on: depends_on:
- dspacedb - dspacedb
networks: networks:
dspacenet: - dspacenet
ports: ports:
- published: 8080 - published: 8080
target: 8080 target: 8080
@@ -51,8 +51,6 @@ services:
tty: true tty: true
volumes: volumes:
- assetstore:/dspace/assetstore - assetstore:/dspace/assetstore
# Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below)
- solr_configs:/dspace/solr
# Ensure that the database is ready BEFORE starting tomcat # Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
# 2. Then, run database migration to init database tables # 2. Then, run database migration to init database tables
@@ -69,25 +67,23 @@ services:
container_name: dspacedb container_name: dspacedb
environment: environment:
PGDATA: /pgdata PGDATA: /pgdata
image: dspace/dspace-postgres-pgcrypto image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
networks: networks:
dspacenet: - dspacenet
ports: ports:
- published: 5432 - published: 5432
target: 5432 target: 5432
stdin_open: true stdin_open: true
tty: true tty: true
volumes: volumes:
# Keep Postgres data directory between reboots
- pgdata:/pgdata - pgdata:/pgdata
# DSpace Solr container # DSpace Solr container
dspacesolr: dspacesolr:
container_name: dspacesolr container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
# Needs main 'dspace' container to start first to guarantee access to solr_configs
depends_on:
- dspace
networks: networks:
dspacenet: - dspacenet
ports: ports:
- published: 8983 - published: 8983
target: 8983 target: 8983
@@ -115,10 +111,10 @@ services:
cp -r /opt/solr/server/solr/configsets/search/* search cp -r /opt/solr/server/solr/configsets/search/* search
precreate-core statistics /opt/solr/server/solr/configsets/statistics precreate-core statistics /opt/solr/server/solr/configsets/statistics
cp -r /opt/solr/server/solr/configsets/statistics/* statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
exec solr -f exec solr -f
volumes: volumes:
assetstore: assetstore:
pgdata: pgdata:
solr_data: solr_data:
# Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above)
solr_configs:

View File

@@ -15,7 +15,10 @@ module.exports = function (config) {
], ],
client: { client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser clearContext: false, // leave Jasmine Spec Runner output visible in browser
captureConsole: false captureConsole: false,
jasmine: {
failSpecWithNoExpectations: true
}
}, },
coverageIstanbulReporter: { coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/dspace-angular'), dir: require('path').join(__dirname, './coverage/dspace-angular'),

View File

@@ -121,14 +121,14 @@
"ngx-infinite-scroll": "^15.0.0", "ngx-infinite-scroll": "^15.0.0",
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0", "ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^14.0.3", "ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1", "nouislider": "^15.7.1",
"pem": "1.14.7", "pem": "1.14.7",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sanitize-html": "^2.10.0", "sanitize-html": "^2.12.1",
"sortablejs": "1.15.0", "sortablejs": "1.15.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webfontloader": "1.6.28", "webfontloader": "1.6.28",
@@ -142,7 +142,7 @@
"@angular-eslint/eslint-plugin-template": "15.2.1", "@angular-eslint/eslint-plugin-template": "15.2.1",
"@angular-eslint/schematics": "15.2.1", "@angular-eslint/schematics": "15.2.1",
"@angular-eslint/template-parser": "15.2.1", "@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "^15.2.6", "@angular/cli": "^16.0.4",
"@angular/compiler-cli": "^15.2.8", "@angular/compiler-cli": "^15.2.8",
"@angular/language-service": "^15.2.8", "@angular/language-service": "^15.2.8",
"@cypress/schematic": "^1.5.0", "@cypress/schematic": "^1.5.0",
@@ -170,9 +170,12 @@
"eslint": "^8.39.0", "eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.4.1", "eslint-plugin-deprecation": "^1.4.1",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.7", "express-static-gzip": "^2.1.7",
"jasmine-core": "^3.8.0", "jasmine-core": "^3.8.0",

View File

@@ -1,5 +1,5 @@
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAccessControlModuleRoute } from '../app-routing-paths'; import { getAccessControlModuleRoute } from '../app-routing-paths';
import { URLCombiner } from '../core/url-combiner/url-combiner';
export const EPERSON_PATH = 'epeople'; export const EPERSON_PATH = 'epeople';

View File

@@ -1,20 +1,20 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { EPERSON_PATH, GROUP_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 { 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 { import {
GroupAdministratorGuard EPERSON_PATH,
} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; GROUP_PATH,
import { } from './access-control-routing-paths';
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { BulkAccessComponent } from './bulk-access/bulk-access.component'; import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupPageGuard } from './group-registry/group-page.guard';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -23,10 +23,10 @@ import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
path: EPERSON_PATH, path: EPERSON_PATH,
component: EPeopleRegistryComponent, component: EPeopleRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver,
}, },
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }, data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
canActivate: [SiteAdministratorGuard] canActivate: [SiteAdministratorGuard],
}, },
{ {
path: `${EPERSON_PATH}/create`, path: `${EPERSON_PATH}/create`,
@@ -51,40 +51,40 @@ import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
path: GROUP_PATH, path: GROUP_PATH,
component: GroupsRegistryComponent, component: GroupsRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver,
}, },
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }, data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard],
}, },
{ {
path: `${GROUP_PATH}/create`, path: `${GROUP_PATH}/create`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver,
}, },
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }, data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard],
}, },
{ {
path: `${GROUP_PATH}/:groupId/edit`, path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver,
}, },
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', path: 'bulk-access',
component: BulkAccessComponent, component: BulkAccessComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver,
}, },
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: [SiteAdministratorGuard] canActivate: [SiteAdministratorGuard],
}, },
]) ]),
] ],
}) })
/** /**
* Routing module for the AccessControl section of the admin sidebar * Routing module for the AccessControl section of the admin sidebar

View File

@@ -1,23 +1,27 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import {
DYNAMIC_ERROR_MESSAGES_MATCHER,
DynamicErrorMessagesMatcher,
} from '@ng-dynamic-forms/core';
import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';
import { FormModule } from '../shared/form/form.module';
import { SearchModule } from '../shared/search/search.module';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { AccessControlRoutingModule } from './access-control-routing.module'; import { AccessControlRoutingModule } from './access-control-routing.module';
import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component'; import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component'; import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
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
@@ -55,9 +59,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
providers: [ providers: [
{ {
provide: DYNAMIC_ERROR_MESSAGES_MATCHER, provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher useValue: ValidateEmailErrorStateMatcher,
}, },
] ],
}) })
/** /**
* This module handles all components related to the access control pages * This module handles all components related to the access control pages

View File

@@ -1,15 +1,15 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'"> <ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
<ngb-panel [id]="'browse'"> <ngb-panel [id]="'browse'">
<ng-template ngbPanelHeader> <ng-template ngbPanelHeader>
<div class="w-100 d-flex justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')" <div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')"
data-test="browse"> data-test="browse">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()" <button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
[attr.aria-expanded]="!acc.isExpanded('browse')" [attr.aria-expanded]="acc.isExpanded('browse')"
aria-controls="collapsePanels"> aria-controls="bulk-access-browse-panel-content">
{{ 'admin.access-control.bulk-access-browse.header' | translate }} {{ 'admin.access-control.bulk-access-browse.header' | translate }}
</button> </button>
<div class="text-right d-flex"> <div class="text-right d-flex gap-2">
<div class="ml-3 d-inline-block"> <div class="d-flex my-auto">
<span *ngIf="acc.isExpanded('browse')" class="fas fa-chevron-up fa-fw"></span> <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> <span *ngIf="!acc.isExpanded('browse')" class="fas fa-chevron-down fa-fw"></span>
</div> </div>
@@ -17,8 +17,9 @@
</div> </div>
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<div id="bulk-access-browse-panel-content">
<ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills"> <ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills">
<li [ngbNavItem]="'search'"> <li [ngbNavItem]="'search'" role="presentation">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a> <a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mx-n3"> <div class="mx-n3">
@@ -29,7 +30,7 @@
</div> </div>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="'selected'"> <li [ngbNavItem]="'selected'" role="presentation">
<a ngbNavLink> <a ngbNavLink>
{{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }} {{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }}
</a> </a>
@@ -62,6 +63,7 @@
</li> </li>
</ul> </ul>
<div [ngbNavOutlet]="nav" class="mt-5"></div> <div [ngbNavOutlet]="nav" class="mt-5"></div>
</div>
</ng-template> </ng-template>
</ngb-panel> </ngb-panel>
</ngb-accordion> </ngb-accordion>

View File

@@ -1,16 +1,22 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
import { of } from 'rxjs'; ComponentFixture,
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
NgbAccordionModule,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec'; 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'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
describe('BulkAccessBrowseComponent', () => { describe('BulkAccessBrowseComponent', () => {
let component: BulkAccessBrowseComponent; let component: BulkAccessBrowseComponent;
@@ -31,13 +37,13 @@ describe('BulkAccessBrowseComponent', () => {
imports: [ imports: [
NgbAccordionModule, NgbAccordionModule,
NgbNavModule, NgbNavModule,
TranslateModule.forRoot() TranslateModule.forRoot(),
], ],
declarations: [BulkAccessBrowseComponent], declarations: [BulkAccessBrowseComponent],
providers: [ { provide: SelectableListService, useValue: selectableListService }, ], providers: [ { provide: SelectableListService, useValue: selectableListService } ],
schemas: [ schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA,
] ],
}).compileComponents(); }).compileComponents();
})); }));
@@ -72,7 +78,7 @@ describe('BulkAccessBrowseComponent', () => {
'elementsPerPage': 5, 'elementsPerPage': 5,
'totalElements': 2, 'totalElements': 2,
'totalPages': 1, 'totalPages': 1,
'currentPage': 1 'currentPage': 1,
}), [selected1, selected2]) ; }), [selected1, selected2]) ;
const rd = createSuccessfulRemoteDataObject(list); const rd = createSuccessfulRemoteDataObject(list);

View File

@@ -1,19 +1,32 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import {
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import {
BehaviorSubject,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
map,
} from 'rxjs/operators';
import { BehaviorSubject, Subscription } from 'rxjs'; import {
import { distinctUntilChanged, map } from 'rxjs/operators'; buildPaginatedList,
PaginatedList,
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; } from '../../../core/data/paginated-list.model';
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 { 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 { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
@Component({ @Component({
selector: 'ds-bulk-access-browse', selector: 'ds-bulk-access-browse',
@@ -22,9 +35,9 @@ import { hasValue } from '../../../shared/empty.util';
providers: [ providers: [
{ {
provide: SEARCH_CONFIG_SERVICE, provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService useClass: SearchConfigurationService,
} },
] ],
}) })
export class BulkAccessBrowseComponent implements OnInit, OnDestroy { export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
@@ -49,7 +62,7 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), { paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), {
id: 'bas', id: 'bas',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
})); }));
/** /**
@@ -67,20 +80,20 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
this.subs.push( this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe( this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(), distinctUntilChanged(),
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)) map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)),
).subscribe(this.objectsSelected$) ).subscribe(this.objectsSelected$),
); );
} }
pageNext() { pageNext() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage + 1 currentPage: this.paginationOptions$.value.currentPage + 1,
})); }));
} }
pagePrev() { pagePrev() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage - 1 currentPage: this.paginationOptions$.value.currentPage - 1,
})); }));
} }
@@ -99,12 +112,12 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
elementsPerPage: this.paginationOptions$.value.pageSize, elementsPerPage: this.paginationOptions$.value.pageSize,
totalElements: list?.selection.length, totalElements: list?.selection.length,
totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length),
currentPage: this.paginationOptions$.value.currentPage currentPage: this.paginationOptions$.value.currentPage,
}); });
if (pageInfo.currentPage > pageInfo.totalPages) { if (pageInfo.currentPage > pageInfo.totalPages) {
pageInfo.currentPage = pageInfo.totalPages; pageInfo.currentPage = pageInfo.totalPages;
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: pageInfo.currentPage currentPage: pageInfo.currentPage,
})); }));
} }
return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || []));

View File

@@ -1,4 +1,5 @@
<div class="container"> <div class="container">
<h1>{{ 'admin.access-control.bulk-access.title' | translate }}</h1>
<ds-bulk-access-browse [listId]="listId"></ds-bulk-access-browse> <ds-bulk-access-browse [listId]="listId"></ds-bulk-access-browse>
<div class="clearfix mb-3"></div> <div class="clearfix mb-3"></div>
<ds-bulk-access-settings #dsBulkSettings ></ds-bulk-access-settings> <ds-bulk-access-settings #dsBulkSettings ></ds-bulk-access-settings>

View File

@@ -1,18 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs'; 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 { Process } from '../../process-page/processes/process.model';
import { RouterTestingModule } from '@angular/router/testing'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { BulkAccessComponent } from './bulk-access.component';
describe('BulkAccessComponent', () => { describe('BulkAccessComponent', () => {
let component: BulkAccessComponent; let component: BulkAccessComponent;
@@ -31,35 +33,35 @@ describe('BulkAccessComponent', () => {
'startDate': { 'startDate': {
'year': 2026, 'year': 2026,
'month': 5, 'month': 5,
'day': 31 'day': 31,
},
'endDate': null,
}, },
'endDate': null
}
], ],
'state': { 'state': {
'item': { 'item': {
'toggleStatus': true, 'toggleStatus': true,
'accessMode': 'replace' 'accessMode': 'replace',
}, },
'bitstream': { 'bitstream': {
'toggleStatus': false, 'toggleStatus': false,
'accessMode': '', 'accessMode': '',
'changesLimit': '', 'changesLimit': '',
'selectedBitstreams': [] 'selectedBitstreams': [],
} },
} },
}; };
const mockFile = { const mockFile = {
'uuids': [ 'uuids': [
'1234', '5678' '1234', '5678',
], ],
'file': { } 'file': { },
}; };
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getValue: jasmine.createSpy('getValue'), getValue: jasmine.createSpy('getValue'),
reset: jasmine.createSpy('reset') reset: jasmine.createSpy('reset'),
}); });
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
const selectableListState: SelectableListState = { id: 'test', selection }; const selectableListState: SelectableListState = { id: 'test', selection };
@@ -71,15 +73,15 @@ describe('BulkAccessComponent', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
RouterTestingModule, RouterTestingModule,
TranslateModule.forRoot() TranslateModule.forRoot(),
], ],
declarations: [ BulkAccessComponent ], declarations: [ BulkAccessComponent ],
providers: [ providers: [
{ provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock },
{ provide: NotificationsService, useValue: NotificationsServiceStub }, { provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: SelectableListService, useValue: selectableListServiceMock } { provide: SelectableListService, useValue: selectableListServiceMock },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}) })
.compileComponents(); .compileComponents();
}); });

View File

@@ -1,17 +1,26 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import {
Component,
OnInit,
ViewChild,
} from '@angular/core';
import {
BehaviorSubject,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
map,
} from 'rxjs/operators';
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 { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
@Component({ @Component({
selector: 'ds-bulk-access', selector: 'ds-bulk-access',
templateUrl: './bulk-access.component.html', templateUrl: './bulk-access.component.html',
styleUrls: ['./bulk-access.component.scss'] styleUrls: ['./bulk-access.component.scss'],
}) })
export class BulkAccessComponent implements OnInit { export class BulkAccessComponent implements OnInit {
@@ -37,7 +46,7 @@ export class BulkAccessComponent implements OnInit {
constructor( constructor(
private bulkAccessControlService: BulkAccessControlService, private bulkAccessControlService: BulkAccessControlService,
private selectableListService: SelectableListService private selectableListService: SelectableListService,
) { ) {
} }
@@ -45,8 +54,8 @@ export class BulkAccessComponent implements OnInit {
this.subs.push( this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe( this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(), distinctUntilChanged(),
map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) map((list: SelectableListState) => this.generateIdListBySelectedElements(list)),
).subscribe(this.objectsSelected$) ).subscribe(this.objectsSelected$),
); );
} }
@@ -74,12 +83,12 @@ export class BulkAccessComponent implements OnInit {
const { file } = this.bulkAccessControlService.createPayloadFile({ const { file } = this.bulkAccessControlService.createPayloadFile({
bitstreamAccess, bitstreamAccess,
itemAccess, itemAccess,
state: settings.state state: settings.state,
}); });
this.bulkAccessControlService.executeScript( this.bulkAccessControlService.executeScript(
this.objectsSelected$.value || [], this.objectsSelected$.value || [],
file file,
).subscribe(); ).subscribe();
} }

View File

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

View File

@@ -1,8 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { BulkAccessSettingsComponent } from './bulk-access-settings.component'; import { BulkAccessSettingsComponent } from './bulk-access-settings.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('BulkAccessSettingsComponent', () => { describe('BulkAccessSettingsComponent', () => {
let component: BulkAccessSettingsComponent; let component: BulkAccessSettingsComponent;
@@ -15,35 +19,35 @@ describe('BulkAccessSettingsComponent', () => {
'startDate': { 'startDate': {
'year': 2026, 'year': 2026,
'month': 5, 'month': 5,
'day': 31 'day': 31,
},
'endDate': null,
}, },
'endDate': null
}
], ],
'state': { 'state': {
'item': { 'item': {
'toggleStatus': true, 'toggleStatus': true,
'accessMode': 'replace' 'accessMode': 'replace',
}, },
'bitstream': { 'bitstream': {
'toggleStatus': false, 'toggleStatus': false,
'accessMode': '', 'accessMode': '',
'changesLimit': '', 'changesLimit': '',
'selectedBitstreams': [] 'selectedBitstreams': [],
} },
} },
}; };
const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getFormValue: jasmine.createSpy('getFormValue'), getFormValue: jasmine.createSpy('getFormValue'),
reset: jasmine.createSpy('reset') reset: jasmine.createSpy('reset'),
}); });
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [NgbAccordionModule, TranslateModule.forRoot()], imports: [NgbAccordionModule, TranslateModule.forRoot()],
declarations: [BulkAccessSettingsComponent], declarations: [BulkAccessSettingsComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
}); });

View File

@@ -1,13 +1,15 @@
import { Component, ViewChild } from '@angular/core';
import { import {
AccessControlFormContainerComponent Component,
} from '../../../shared/access-control-form-container/access-control-form-container.component'; ViewChild,
} from '@angular/core';
import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component';
@Component({ @Component({
selector: 'ds-bulk-access-settings', selector: 'ds-bulk-access-settings',
templateUrl: 'bulk-access-settings.component.html', templateUrl: 'bulk-access-settings.component.html',
styleUrls: ['./bulk-access-settings.component.scss'], styleUrls: ['./bulk-access-settings.component.scss'],
exportAs: 'dsBulkSettings' exportAs: 'dsBulkSettings',
}) })
export class BulkAccessSettingsComponent { export class BulkAccessSettingsComponent {

View File

@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { type } from '../../shared/ngrx/type'; import { type } from '../../shared/ngrx/type';

View File

@@ -2,7 +2,7 @@
<div class="epeople-registry row"> <div class="epeople-registry row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2> <h1 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h1>
<div> <div>
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
@@ -13,9 +13,9 @@
</div> </div>
</div> </div>
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}} <h2 id="search" class="border-bottom pb-2">
{{labelPrefix + 'search.head' | translate}}
</h3> </h2>
<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">
@@ -43,7 +43,7 @@
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading> <ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)" *ngIf="(pageInfoState$ | async)?.totalElements > 0 && (searching$ | async) !== true"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="pageInfoState$" [pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements" [collectionSize]="(pageInfoState$ | async)?.totalElements"
@@ -87,7 +87,7 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> <div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}} {{labelPrefix + 'no-items' | translate}}
</div> </div>
</div> </div>

View File

@@ -1,47 +1,71 @@
import { Router } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import {
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; DebugElement,
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; NO_ERRORS_SCHEMA,
import { BrowserModule, By } from '@angular/platform-browser'; } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import {
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; ComponentFixture,
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; fakeAsync,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import { Router } from '@angular/router';
import {
NgbModal,
NgbModule,
} from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FindListOptions } from '../../core/data/find-list-options.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 { RequestService } from '../../core/data/request.service';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { EPeopleRegistryComponent } from './epeople-registry.component';
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
EPersonMock,
EPersonMock2,
} from '../../shared/testing/eperson.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { RouterStub } from '../../shared/testing/router.stub';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { RequestService } from '../../core/data/request.service';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model'; import { RouterStub } from '../../shared/testing/router.stub';
import { EPeopleRegistryComponent } from './epeople-registry.component';
describe('EPeopleRegistryComponent', () => { describe('EPeopleRegistryComponent', () => {
let component: EPeopleRegistryComponent; let component: EPeopleRegistryComponent;
let fixture: ComponentFixture<EPeopleRegistryComponent>; let fixture: ComponentFixture<EPeopleRegistryComponent>;
let translateService: TranslateService;
let builderService: FormBuilderService; let builderService: FormBuilderService;
let mockEPeople; let mockEPeople: EPerson[];
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let modalService; let modalService: NgbModal;
let paginationService: PaginationServiceStub;
let paginationService; beforeEach(waitForAsync(async () => {
beforeEach(waitForAsync(() => {
jasmine.getEnv().allowRespy(true); jasmine.getEnv().allowRespy(true);
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
@@ -52,7 +76,7 @@ describe('EPeopleRegistryComponent', () => {
elementsPerPage: this.allEpeople.length, elementsPerPage: this.allEpeople.length,
totalElements: this.allEpeople.length, totalElements: this.allEpeople.length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), this.allEpeople)); }), this.allEpeople));
}, },
getActiveEPerson(): Observable<EPerson> { getActiveEPerson(): Observable<EPerson> {
@@ -67,7 +91,7 @@ describe('EPeopleRegistryComponent', () => {
elementsPerPage: [result].length, elementsPerPage: [result].length,
totalElements: [result].length, totalElements: [result].length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), [result])); }), [result]));
} }
if (scope === 'metadata') { if (scope === 'metadata') {
@@ -76,7 +100,7 @@ describe('EPeopleRegistryComponent', () => {
elementsPerPage: this.allEpeople.length, elementsPerPage: this.allEpeople.length,
totalElements: this.allEpeople.length, totalElements: this.allEpeople.length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), this.allEpeople)); }), this.allEpeople));
} }
const result = this.allEpeople.find((ePerson: EPerson) => { const result = this.allEpeople.find((ePerson: EPerson) => {
@@ -86,14 +110,14 @@ describe('EPeopleRegistryComponent', () => {
elementsPerPage: [result].length, elementsPerPage: [result].length,
totalElements: [result].length, totalElements: [result].length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), [result])); }), [result]));
} }
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
elementsPerPage: this.allEpeople.length, elementsPerPage: this.allEpeople.length,
totalElements: this.allEpeople.length, totalElements: this.allEpeople.length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), this.allEpeople)); }), this.allEpeople));
}, },
deleteEPerson(ePerson: EPerson): Observable<boolean> { deleteEPerson(ePerson: EPerson): Observable<boolean> {
@@ -113,23 +137,17 @@ describe('EPeopleRegistryComponent', () => {
}, },
getEPeoplePageRouterLink(): string { getEPeoplePageRouterLink(): string {
return '/access-control/epeople'; return '/access-control/epeople';
} },
}; };
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true),
}); });
builderService = getMockFormBuilderService(); builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot(),
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
], ],
declarations: [EPeopleRegistryComponent], declarations: [EPeopleRegistryComponent],
providers: [ providers: [
@@ -139,16 +157,16 @@ describe('EPeopleRegistryComponent', () => {
{ provide: FormBuilderService, useValue: builderService }, { provide: FormBuilderService, useValue: builderService },
{ provide: Router, useValue: new RouterStub() }, { provide: Router, useValue: new RouterStub() },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
{ provide: PaginationService, useValue: paginationService } { provide: PaginationService, useValue: paginationService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(EPeopleRegistryComponent); fixture = TestBed.createComponent(EPeopleRegistryComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
modalService = (component as any).modalService; modalService = TestBed.inject(NgbModal);
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -158,10 +176,10 @@ describe('EPeopleRegistryComponent', () => {
}); });
it('should display list of ePeople', () => { it('should display list of ePeople', () => {
const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
expect(ePeopleIdsFound.length).toEqual(2); expect(ePeopleIdsFound.length).toEqual(2);
mockEPeople.map((ePerson: EPerson) => { mockEPeople.map((ePerson: EPerson) => {
expect(ePeopleIdsFound.find((foundEl) => { expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); return (foundEl.nativeElement.textContent.trim() === ePerson.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
@@ -169,7 +187,7 @@ describe('EPeopleRegistryComponent', () => {
describe('search', () => { describe('search', () => {
describe('when searching with scope/query (scope metadata)', () => { describe('when searching with scope/query (scope metadata)', () => {
let ePeopleIdsFound; let ePeopleIdsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ scope: 'metadata', query: EPersonMock2.name }); component.search({ scope: 'metadata', query: EPersonMock2.name });
tick(); tick();
@@ -179,14 +197,14 @@ describe('EPeopleRegistryComponent', () => {
it('should display search result', () => { it('should display search result', () => {
expect(ePeopleIdsFound.length).toEqual(1); expect(ePeopleIdsFound.length).toEqual(1);
expect(ePeopleIdsFound.find((foundEl) => { expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
}); });
describe('when searching with scope/query (scope email)', () => { describe('when searching with scope/query (scope email)', () => {
let ePeopleIdsFound; let ePeopleIdsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ scope: 'email', query: EPersonMock.email }); component.search({ scope: 'email', query: EPersonMock.email });
tick(); tick();
@@ -196,7 +214,7 @@ describe('EPeopleRegistryComponent', () => {
it('should display search result', () => { it('should display search result', () => {
expect(ePeopleIdsFound.length).toEqual(1); expect(ePeopleIdsFound.length).toEqual(1);
expect(ePeopleIdsFound.find((foundEl) => { expect(ePeopleIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
@@ -212,7 +230,7 @@ describe('EPeopleRegistryComponent', () => {
const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton')); const deleteButtons = fixture.debugElement.queryAll(By.css('.access-control-deleteEPersonButton'));
deleteButtons[0].triggerEventHandler('click', { deleteButtons[0].triggerEventHandler('click', {
preventDefault: () => {/**/ preventDefault: () => {/**/
} },
}); });
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
@@ -228,19 +246,12 @@ describe('EPeopleRegistryComponent', () => {
}); });
}); });
describe('delete EPerson button when the isAuthorized returns false', () => {
let ePeopleDeleteButton; it('should hide delete EPerson button when the isAuthorized returns false', () => {
beforeEach(() => {
spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false));
component.initialisePage(); component.initialisePage();
fixture.detectChanges(); fixture.detectChanges();
});
it('should be disabled', () => { expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull();
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
ePeopleDeleteButton.forEach((deleteButton: DebugElement) => {
expect(deleteButton.nativeElement.disabled).toBe(true);
});
});
}); });
}); });

View File

@@ -1,28 +1,51 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import {
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { UntypedFormBuilder } from '@angular/forms'; import { UntypedFormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; import {
import { map, switchMap, take } from 'rxjs/operators'; BehaviorSubject,
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; combineLatest,
Observable,
Subscription,
} from 'rxjs';
import {
map,
switchMap,
take,
} from 'rxjs/operators';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import {
buildPaginatedList,
PaginatedList,
} from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { RequestService } from '../../core/data/request.service';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { EpersonDtoModel } from '../../core/eperson/models/eperson-dto.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { NoContent } from '../../core/shared/NoContent.model';
import {
getAllSucceededRemoteData,
getFirstCompletedRemoteData,
} from '../../core/shared/operators';
import { PageInfo } from '../../core/shared/page-info.model';
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
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 {
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; getEPersonEditRoute,
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; getEPersonsRoute,
import { getAllSucceededRemoteData, getFirstCompletedRemoteData } from '../../core/shared/operators'; } from '../access-control-routing-paths';
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../core/data/request.service';
import { PageInfo } from '../../core/shared/page-info.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-epeople-registry', selector: 'ds-epeople-registry',
@@ -62,7 +85,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'elp', id: 'elp',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
// The search form // The search form
@@ -110,7 +133,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
initialisePage() { initialisePage() {
this.searching$.next(true); this.searching$.next(true);
this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
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) {
@@ -121,7 +144,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
epersonDtoModel.ableToDelete = authorized; epersonDtoModel.ableToDelete = authorized;
epersonDtoModel.eperson = eperson; epersonDtoModel.eperson = eperson;
return epersonDtoModel; return epersonDtoModel;
}) }),
); );
})).pipe(map((dtos: EpersonDtoModel[]) => { })).pipe(map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epeople.pageInfo, dtos); return buildPaginatedList(epeople.pageInfo, dtos);
@@ -151,14 +174,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
const scope: string = data.scope; const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query) { if (query != null && this.currentSearchQuery !== query) {
void this.router.navigate([getEPersonsRoute()], { void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge' queryParamsHandling: 'merge',
}); });
this.currentSearchQuery = query; this.currentSearchQuery = query;
this.paginationService.resetPage(this.config.id); this.paginationService.resetPage(this.config.id);
} }
if (scope != null && this.currentSearchScope !== scope) { if (scope != null && this.currentSearchScope !== scope) {
void this.router.navigate([getEPersonsRoute()], { void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge' queryParamsHandling: 'merge',
}); });
this.currentSearchScope = scope; this.currentSearchScope = scope;
this.paginationService.resetPage(this.config.id); this.paginationService.resetPage(this.config.id);
@@ -166,15 +189,15 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
} }
return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { return this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
currentPage: findListOptions.currentPage, currentPage: findListOptions.currentPage,
elementsPerPage: findListOptions.pageSize elementsPerPage: findListOptions.pageSize,
}); });
} },
), ),
getAllSucceededRemoteData(), getAllSucceededRemoteData(),
).subscribe((peopleRD) => { ).subscribe((peopleRD) => {
this.ePeople$.next(peopleRD.payload); this.ePeople$.next(peopleRD.payload);
this.pageInfoState$.next(peopleRD.payload.pageInfo); this.pageInfoState$.next(peopleRD.payload.pageInfo);
} },
); );
} }
@@ -184,7 +207,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
isActive(eperson: EPerson): Observable<boolean> { isActive(eperson: EPerson): Observable<boolean> {
return this.getActiveEPerson().pipe( return this.getActiveEPerson().pipe(
map((activeEPerson) => eperson === activeEPerson) map((activeEPerson) => eperson === activeEPerson),
); );
} }
@@ -201,7 +224,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
deleteEPerson(ePerson: EPerson) { deleteEPerson(ePerson: EPerson) {
if (hasValue(ePerson.id)) { if (hasValue(ePerson.id)) {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = ePerson; modalRef.componentInstance.name = this.dsoNameService.getName(ePerson);
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
@@ -213,7 +236,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: this.dsoNameService.getName(ePerson)})); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(ePerson) }));
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage }));
} }
@@ -244,7 +267,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.searchForm.patchValue({ this.searchForm.patchValue({
query: '', query: '',
}); });
this.search({query: ''}); this.search({ query: '' });
} }
getEditEPeoplePage(id: string): string { getEditEPeoplePage(id: string): string {

View File

@@ -1,6 +1,12 @@
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from './epeople-registry.actions';
import { ePeopleRegistryReducer, EPeopleRegistryState } from './epeople-registry.reducers';
import { EPersonMock } from '../../shared/testing/eperson.mock'; import { EPersonMock } from '../../shared/testing/eperson.mock';
import {
EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction,
} from './epeople-registry.actions';
import {
ePeopleRegistryReducer,
EPeopleRegistryState,
} from './epeople-registry.reducers';
const initialState: EPeopleRegistryState = { const initialState: EPeopleRegistryState = {
editEPerson: null, editEPerson: null,

View File

@@ -2,7 +2,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model';
import { import {
EPeopleRegistryAction, EPeopleRegistryAction,
EPeopleRegistryActionTypes, EPeopleRegistryActionTypes,
EPeopleRegistryEditEPersonAction EPeopleRegistryEditEPersonAction,
} from './epeople-registry.actions'; } from './epeople-registry.actions';
/** /**
@@ -30,13 +30,13 @@ export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegi
case EPeopleRegistryActionTypes.EDIT_EPERSON: { case EPeopleRegistryActionTypes.EDIT_EPERSON: {
return Object.assign({}, state, { return Object.assign({}, state, {
editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson,
}); });
} }
case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: {
return Object.assign({}, state, { return Object.assign({}, state, {
editEPerson: null editEPerson: null,
}); });
} }

View File

@@ -5,11 +5,11 @@
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div> <div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
</ng-template> </ng-template>
<ng-template #editHeader> <ng-template #editHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h1>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"
@@ -25,7 +25,7 @@
</button> </button>
</div> </div>
<div *ngIf="displayResetPassword" between class="btn-group"> <div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" type="button" (click)="resetPassword()"> <button class="btn btn-primary" [disabled]="(canReset$ | async) !== true" type="button" (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>
</div> </div>
@@ -45,15 +45,15 @@
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading> <ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<div *ngIf="epersonService.getActiveEPerson() | async"> <div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5> <h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading> <ds-themed-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0" *ngIf="(groups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="(groups | async)?.payload" [pageInfoState]="groupsPageInfoState$"
[collectionSize]="(groups | async)?.payload?.totalElements" [collectionSize]="(groups$ | async)?.payload?.totalElements"
[hideGear]="true" [hideGear]="true"
[hidePagerWhenSinglePage]="true" [hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)"> (pageChange)="onPageChange($event)">
@@ -68,7 +68,7 @@
</tr> </tr>
</thead> </thead>
<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"> <td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)" <a (click)="groupsDataService.startEditingNewGroup(group)"
@@ -84,7 +84,7 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> <div *ngIf="(groups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div> <div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
<div> <div>
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]" <button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"

View File

@@ -1,40 +1,70 @@
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 {
import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; ComponentFixture,
import { BrowserModule, By } from '@angular/platform-browser'; TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormControl,
UntypedFormGroup,
Validators,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import {
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FindListOptions } from '../../../core/data/find-list-options.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 { RequestService } from '../../../core/data/request.service';
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 { EPerson } from '../../../core/eperson/models/eperson.model'; import { EPerson } from '../../../core/eperson/models/eperson.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
import {
EPersonMock,
EPersonMock2,
} from '../../../shared/testing/eperson.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { RouterStub } from '../../../shared/testing/router.stub';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { HasNoValuePipe } from '../../../shared/utils/has-no-value.pipe';
import { EPeopleRegistryComponent } from '../epeople-registry.component'; import { EPeopleRegistryComponent } from '../epeople-registry.component';
import { EPersonFormComponent } from './eperson-form.component'; import { EPersonFormComponent } from './eperson-form.component';
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { AuthService } from '../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router.stub';
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
describe('EPersonFormComponent', () => { describe('EPersonFormComponent', () => {
let component: EPersonFormComponent; let component: EPersonFormComponent;
@@ -115,7 +145,7 @@ describe('EPersonFormComponent', () => {
}, },
findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> { findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null); return createSuccessfulRemoteDataObject$(null);
} },
}; };
builderService = Object.assign(getMockFormBuilderService(),{ builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) { createFormGroup(formModel, options = null) {
@@ -178,7 +208,7 @@ describe('EPersonFormComponent', () => {
}, },
isObject(value) { isObject(value) {
return typeof value === 'object' && value !== null; return typeof value === 'object' && value !== null;
} },
}); });
authService = new AuthServiceStub(); authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
@@ -187,7 +217,7 @@ describe('EPersonFormComponent', () => {
}); });
groupsDataService = jasmine.createSpyObj('groupsDataService', { groupsDataService = jasmine.createSpyObj('groupsDataService', {
findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '' getGroupRegistryRouterLink: '',
}); });
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
@@ -198,11 +228,14 @@ describe('EPersonFormComponent', () => {
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock useClass: TranslateLoaderMock,
} },
}), }),
], ],
declarations: [EPersonFormComponent], declarations: [
EPersonFormComponent,
HasNoValuePipe,
],
providers: [ providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: GroupDataService, useValue: groupsDataService }, { provide: GroupDataService, useValue: groupsDataService },
@@ -211,18 +244,18 @@ describe('EPersonFormComponent', () => {
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService }, { provide: EpersonRegistrationService, useValue: epersonRegistrationService },
{ provide: ActivatedRoute, useValue: route }, { provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
EPeopleRegistryComponent EPeopleRegistryComponent,
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
registerEmail: createSuccessfulRemoteDataObject$(null) registerEmail: createSuccessfulRemoteDataObject$(null),
}); });
beforeEach(() => { beforeEach(() => {
@@ -254,12 +287,12 @@ describe('EPersonFormComponent', () => {
metadata: { metadata: {
'eperson.firstname': [ 'eperson.firstname': [
{ {
value: firstName value: firstName,
} },
], ],
'eperson.lastname': [ 'eperson.lastname': [
{ {
value: lastName value: lastName,
}, },
], ],
}, },
@@ -328,7 +361,7 @@ describe('EPersonFormComponent', () => {
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{ const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
getEPersonByEmail(): Observable<RemoteData<EPerson>> { getEPersonByEmail(): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(EPersonMock); return createSuccessfulRemoteDataObject$(EPersonMock);
} },
}); });
component.formGroup.controls.email.setValue('test@test.com'); component.formGroup.controls.email.setValue('test@test.com');
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson)); component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
@@ -363,12 +396,12 @@ describe('EPersonFormComponent', () => {
metadata: { metadata: {
'eperson.firstname': [ 'eperson.firstname': [
{ {
value: firstName value: firstName,
} },
], ],
'eperson.lastname': [ 'eperson.lastname': [
{ {
value: lastName value: lastName,
}, },
], ],
}, },
@@ -404,19 +437,19 @@ describe('EPersonFormComponent', () => {
metadata: { metadata: {
'eperson.firstname': [ 'eperson.firstname': [
{ {
value: firstName value: firstName,
} },
], ],
'eperson.lastname': [ 'eperson.lastname': [
{ {
value: lastName value: lastName,
}, },
], ],
}, },
email: email, email: email,
canLogIn: canLogIn, canLogIn: canLogIn,
requireCertificate: requireCertificate, requireCertificate: requireCertificate,
_links: undefined _links: undefined,
}); });
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
component.onSubmit(); component.onSubmit();
@@ -436,7 +469,7 @@ describe('EPersonFormComponent', () => {
spyOn(authService, 'impersonate').and.callThrough(); spyOn(authService, 'impersonate').and.callThrough();
ePersonId = 'testEPersonId'; ePersonId = 'testEPersonId';
component.epersonInitial = Object.assign(new EPerson(), { component.epersonInitial = Object.assign(new EPerson(), {
id: ePersonId id: ePersonId,
}); });
component.impersonate(); component.impersonate();
}); });
@@ -524,7 +557,7 @@ describe('EPersonFormComponent', () => {
ePersonEmail = 'person.email@4science.it'; ePersonEmail = 'person.email@4science.it';
component.epersonInitial = Object.assign(new EPerson(), { component.epersonInitial = Object.assign(new EPerson(), {
id: ePersonId, id: ePersonId,
email: ePersonEmail email: ePersonEmail,
}); });
component.resetPassword(); component.resetPassword();
}); });

View File

@@ -1,45 +1,68 @@
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import {
ChangeDetectorRef,
Component,
EventEmitter,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { import {
DynamicCheckboxModel, DynamicCheckboxModel,
DynamicFormControlModel, DynamicFormControlModel,
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel DynamicInputModel,
} 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 {
import { debounceTime, finalize, map, switchMap, take } from 'rxjs/operators'; combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription,
} from 'rxjs';
import {
debounceTime,
finalize,
map,
switchMap,
take,
} from 'rxjs/operators';
import { AuthService } from '../../../core/auth/auth.service';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
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 { RequestService } from '../../../core/data/request.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model'; 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 { PaginationService } from '../../../core/pagination/pagination.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { import {
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getRemoteDataPayload getRemoteDataPayload,
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { PageInfo } from '../../../core/shared/page-info.model';
import { Registration } from '../../../core/shared/registration.model';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
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 { AuthService } from '../../../core/auth/auth.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service';
import { NoContent } from '../../../core/shared/NoContent.model';
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 { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { Registration } from '../../../core/shared/registration.model';
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';
import { ActivatedRoute, Router } from '@angular/router';
import { getEPersonsRoute } from '../../access-control-routing-paths'; import { getEPersonsRoute } from '../../access-control-routing-paths';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
@@ -83,28 +106,28 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
formLayout: DynamicFormLayout = { formLayout: DynamicFormLayout = {
firstName: { firstName: {
grid: { grid: {
host: 'row' host: 'row',
} },
}, },
lastName: { lastName: {
grid: { grid: {
host: 'row' host: 'row',
} },
}, },
email: { email: {
grid: { grid: {
host: 'row' host: 'row',
} },
}, },
canLogIn: { canLogIn: {
grid: { grid: {
host: 'col col-sm-6 d-inline-block' host: 'col col-sm-6 d-inline-block',
} },
}, },
requireCertificate: { requireCertificate: {
grid: { grid: {
host: 'col col-sm-6 d-inline-block' host: 'col col-sm-6 d-inline-block',
} },
}, },
}; };
@@ -147,7 +170,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
/** /**
* A list of all the groups this EPerson is a member of * A list of all the groups this EPerson is a member of
*/ */
groups: Observable<RemoteData<PaginatedList<Group>>>; groups$: Observable<RemoteData<PaginatedList<Group>>>;
/**
* The pagination of the {@link groups$} list.
*/
groupsPageInfoState$: Observable<PageInfo>;
/** /**
* Pagination config used to display the list of groups * Pagination config used to display the list of groups
@@ -155,7 +183,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'gem', id: 'gem',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
/** /**
@@ -257,23 +285,23 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
required: true, required: true,
errorMessages: { errorMessages: {
emailTaken: 'error.validation.emailTaken', emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail' pattern: 'error.validation.NotValidEmail',
}, },
hint: emailHint hint: emailHint,
}); });
this.canLogIn = new DynamicCheckboxModel( this.canLogIn = new DynamicCheckboxModel(
{ {
id: 'canLogIn', id: 'canLogIn',
label: canLogIn, label: canLogIn,
name: 'canLogIn', name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true) value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true),
}); });
this.requireCertificate = new DynamicCheckboxModel( this.requireCertificate = new DynamicCheckboxModel(
{ {
id: 'requireCertificate', id: 'requireCertificate',
label: requireCertificate, label: requireCertificate,
name: 'requireCertificate', name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false) value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false),
}); });
this.formModel = [ this.formModel = [
this.firstName, this.firstName,
@@ -285,9 +313,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null) { if (eperson != null) {
this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize,
}); });
} }
this.formGroup.patchValue({ this.formGroup.patchValue({
@@ -295,7 +323,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
email: eperson != null ? eperson.email : '', email: eperson != null ? eperson.email : '',
canLogIn: eperson != null ? eperson.canLogIn : true, canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false requireCertificate: eperson != null ? eperson.requireCertificate : false,
}); });
if (eperson === null && !!this.formGroup.controls.email) { if (eperson === null && !!this.formGroup.controls.email) {
@@ -308,11 +336,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
const activeEPerson$ = this.epersonService.getActiveEPerson(); const activeEPerson$ = this.epersonService.getActiveEPerson();
this.groups = activeEPerson$.pipe( this.groups$ = activeEPerson$.pipe(
switchMap((eperson) => { switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1, currentPage: 1,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize,
})]); })]);
}), }),
switchMap(([eperson, findListOptions]) => { switchMap(([eperson, findListOptions]) => {
@@ -320,7 +348,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
} }
return observableOf(undefined); return observableOf(undefined);
}) }),
);
this.groupsPageInfoState$ = this.groups$.pipe(
map(groupsRD => groupsRD.payload.pageInfo),
); );
this.canImpersonate$ = activeEPerson$.pipe( this.canImpersonate$ = activeEPerson$.pipe(
@@ -330,10 +362,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} else { } else {
return observableOf(false); return observableOf(false);
} }
}) }),
); );
this.canDelete$ = activeEPerson$.pipe( this.canDelete$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)),
); );
this.canReset$ = observableOf(true); this.canReset$ = observableOf(true);
}); });
@@ -361,12 +393,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
metadata: { metadata: {
'eperson.firstname': [ 'eperson.firstname': [
{ {
value: this.firstName.value value: this.firstName.value,
} },
], ],
'eperson.lastname': [ 'eperson.lastname': [
{ {
value: this.lastName.value value: this.lastName.value,
}, },
], ],
}, },
@@ -379,7 +411,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} else { } else {
this.editEPerson(ePerson, values); this.editEPerson(ePerson, values);
} }
} },
); );
} }
@@ -392,7 +424,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
const response = this.epersonService.create(ePersonToCreate); const response = this.epersonService.create(ePersonToCreate);
response.pipe( response.pipe(
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: this.dsoNameService.getName(ePersonToCreate) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) }));
@@ -418,12 +450,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
metadata: { metadata: {
'eperson.firstname': [ 'eperson.firstname': [
{ {
value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')) value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')),
} },
], ],
'eperson.lastname': [ 'eperson.lastname': [
{ {
value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')) value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')),
}, },
], ],
}, },
@@ -457,7 +489,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
onPageChange(event) { onPageChange(event) {
this.updateGroups({ this.updateGroups({
currentPage: event, currentPage: event,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize,
}); });
} }
@@ -478,7 +510,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
take(1), take(1),
switchMap((eperson: EPerson) => { switchMap((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson; modalRef.componentInstance.name = this.dsoNameService.getName(eperson);
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
@@ -493,15 +525,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.canDelete$ = observableOf(false); this.canDelete$ = observableOf(false);
return this.epersonService.deleteEPerson(eperson).pipe( return this.epersonService.deleteEPerson(eperson).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
map((restResponse: RemoteData<NoContent>) => ({ restResponse, eperson })) map((restResponse: RemoteData<NoContent>) => ({ restResponse, eperson })),
); );
} else { } else {
return observableOf(null); return observableOf(null);
} }
}), }),
finalize(() => this.canDelete$ = observableOf(true)) finalize(() => this.canDelete$ = observableOf(true)),
); );
}) }),
).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => { ).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => {
if (restResponse?.hasSucceeded) { if (restResponse?.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) }));
@@ -531,12 +563,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
.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'),
this.translateService.get('forgot-email.form.success.content', {email: this.epersonInitial.email})); this.translateService.get('forgot-email.form.success.content', { email: this.epersonInitial.email }));
} else { } else {
this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'), this.notificationsService.error(this.translateService.get('forgot-email.form.error.head'),
this.translateService.get('forgot-email.form.error.content', {email: this.epersonInitial.email})); this.translateService.get('forgot-email.form.error.content', { email: this.epersonInitial.email }));
}
} }
},
); );
} }
} }
@@ -562,13 +594,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
// Relevant message for email in use // Relevant message for email in use
this.subs.push(this.epersonService.searchByScope('email', ePerson.email, { this.subs.push(this.epersonService.searchByScope('email', ePerson.email, {
currentPage: 1, currentPage: 1,
elementsPerPage: 0 elementsPerPage: 0,
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.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: this.dsoNameService.getName(ePerson), name: this.dsoNameService.getName(ePerson),
email: ePerson.email email: ePerson.email,
})); }));
} }
})); }));
@@ -579,7 +611,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
private updateGroups(options) { private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options);
})); }));
} }
} }

View File

@@ -1,9 +1,12 @@
import { AbstractControl, ValidationErrors } from '@angular/forms'; import {
AbstractControl,
ValidationErrors,
} from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { getFirstSucceededRemoteData, } from '../../../../core/shared/operators'; import { getFirstSucceededRemoteData } from '../../../../core/shared/operators';
export class ValidateEmailNotTaken { export class ValidateEmailNotTaken {
@@ -17,8 +20,8 @@ export class ValidateEmailNotTaken {
.pipe( .pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
map(res => { map(res => {
return !!res.payload ? { emailTaken: true } : null; return res.payload ? { emailTaken: true } : null;
}) }),
); );
}; };
} }

View File

@@ -1,13 +1,21 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import {
import { Observable } from 'rxjs'; ActivatedRouteSnapshot,
import { EPerson } from '../../core/eperson/models/eperson.model'; Resolve,
import { RemoteData } from '../../core/data/remote-data'; RouterStateSnapshot,
import { getFirstCompletedRemoteData } from '../../core/shared/operators'; } from '@angular/router';
import { ResolvedAction } from '../../core/resolving/resolver.actions';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { ResolvedAction } from '../../core/resolving/resolver.actions';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import {
followLink,
FollowLinkConfig,
} from '../../shared/utils/follow-link-config.model';
export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig<EPerson>[] = [ export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig<EPerson>[] = [
followLink('groups'), followLink('groups'),

View File

@@ -5,11 +5,11 @@
<div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div> <div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h1>
</ng-template> </ng-template>
<ng-template #editHeader> <ng-template #editHeader>
<h2 class="border-bottom pb-2"> <h1 class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage', content: 'admin.access-control.groups.form.tooltip.editGroupPage',
@@ -20,12 +20,12 @@
> >
{{messagePrefix + '.head.edit' | translate}} {{messagePrefix + '.head.edit' | translate}}
</span> </span>
</h2> </h1>
</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) !== true && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName((getLinkedDSO(groupBeingEdited) | async)?.payload), 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>
@@ -39,19 +39,18 @@
<button (click)="onCancel()" type="button" <button (click)="onCancel()" type="button"
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 after *ngIf="(canEdit$ | async) && !groupBeingEdited.permanent" class="btn-group"> <div after *ngIf="(canEdit$ | async) && !groupBeingEdited?.permanent" class="btn-group">
<button class="btn btn-danger delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent" <button (click)="delete()" class="btn btn-danger delete-button" type="button">
(click)="delete()" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}} <i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button> </button>
</div> </div>
</ds-form> </ds-form>
<div class="mb-5"> <div class="mb-5">
<ds-members-list *ngIf="groupBeingEdited != null" <ds-members-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list> [messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
</div> </div>
<ds-subgroups-list *ngIf="groupBeingEdited != null" <ds-subgroups-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list> [messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>

View File

@@ -1,43 +1,73 @@
import { CommonModule } from '@angular/common'; 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 {
import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; ComponentFixture,
import { BrowserModule, By } from '@angular/platform-browser'; TestBed,
import { ActivatedRoute, Router } from '@angular/router'; waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormControl,
UntypedFormGroup,
Validators,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import {
import { Observable, of as observableOf } from 'rxjs'; TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Operation } from 'fast-json-patch';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { DSOChangeAnalyzer } from '../../../core/data/dso-change-analyzer.service'; import { DSOChangeAnalyzer } from '../../../core/data/dso-change-analyzer.service';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { 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';
import { Group } from '../../../core/eperson/models/group.model'; import { Group } from '../../../core/eperson/models/group.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
import { UUIDService } from '../../../core/shared/uuid.service'; import { UUIDService } from '../../../core/shared/uuid.service';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock';
import { GroupFormComponent } from './group-form.component';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock';
import { RouterMock } from '../../../shared/mocks/router.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
import { NoContent } from '../../../core/shared/NoContent.model';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { RouterMock } from '../../../shared/mocks/router.mock';
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import {
GroupMock,
GroupMock2,
} from '../../../shared/testing/group-mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock';
import { GroupFormComponent } from './group-form.component';
import { ValidateGroupExists } from './validators/group-exists.validator';
describe('GroupFormComponent', () => { describe('GroupFormComponent', () => {
let component: GroupFormComponent; let component: GroupFormComponent;
@@ -65,8 +95,8 @@ describe('GroupFormComponent', () => {
metadata: { metadata: {
'dc.description': [ 'dc.description': [
{ {
value: groupDescription value: groupDescription,
} },
], ],
}, },
}); });
@@ -105,7 +135,7 @@ describe('GroupFormComponent', () => {
create(group: Group): Observable<RemoteData<Group>> { create(group: Group): Observable<RemoteData<Group>> {
this.allGroups = [...this.allGroups, group]; this.allGroups = [...this.allGroups, group];
this.createdGroup = Object.assign({}, group, { this.createdGroup = Object.assign({}, group, {
_links: { self: { href: 'group-selflink' } } _links: { self: { href: 'group-selflink' } },
}); });
return createSuccessfulRemoteDataObject$(this.createdGroup); return createSuccessfulRemoteDataObject$(this.createdGroup);
}, },
@@ -114,15 +144,15 @@ describe('GroupFormComponent', () => {
}, },
getGroupEditPageRouterLinkWithID(id: string) { getGroupEditPageRouterLinkWithID(id: string) {
return `group-edit-page-for-${id}`; return `group-edit-page-for-${id}`;
} },
}; };
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true),
}); });
dsoDataServiceStub = { dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> { findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return null; return null;
} },
}; };
builderService = Object.assign(getMockFormBuilderService(),{ builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) { createFormGroup(formModel, options = null) {
@@ -185,7 +215,7 @@ describe('GroupFormComponent', () => {
}, },
isObject(value) { isObject(value) {
return typeof value === 'object' && value !== null; return typeof value === 'object' && value !== null;
} },
}); });
translateService = getMockTranslateService(); translateService = getMockTranslateService();
router = new RouterMock(); router = new RouterMock();
@@ -195,8 +225,8 @@ describe('GroupFormComponent', () => {
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock useClass: TranslateLoaderMock,
} },
}), }),
], ],
declarations: [GroupFormComponent], declarations: [GroupFormComponent],
@@ -216,12 +246,12 @@ describe('GroupFormComponent', () => {
{ provide: HALEndpointService, useValue: {} }, { provide: HALEndpointService, useValue: {} },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) },
}, },
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));
@@ -257,8 +287,8 @@ describe('GroupFormComponent', () => {
metadata: { metadata: {
'dc.description': [ 'dc.description': [
{ {
value: groupDescription value: groupDescription,
} },
], ],
}, },
}); });
@@ -273,11 +303,11 @@ describe('GroupFormComponent', () => {
const operations = [{ const operations = [{
op: 'add', op: 'add',
path: '/metadata/dc.description', path: '/metadata/dc.description',
value: 'testDescription' value: 'testDescription',
}, { }, {
op: 'replace', op: 'replace',
path: '/name', path: '/name',
value: 'newGroupName' value: 'newGroupName',
}]; }];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
}); });
@@ -289,7 +319,7 @@ describe('GroupFormComponent', () => {
const operations = [{ const operations = [{
op: 'add', op: 'add',
path: '/metadata/dc.description', path: '/metadata/dc.description',
value: 'testDescription' value: 'testDescription',
}]; }];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
}); });
@@ -301,7 +331,7 @@ describe('GroupFormComponent', () => {
const operations = [{ const operations = [{
op: 'replace', op: 'replace',
path: '/name', path: '/name',
value: 'newGroupName' value: 'newGroupName',
}]; }];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
}); });
@@ -338,8 +368,8 @@ describe('GroupFormComponent', () => {
metadata: { metadata: {
'dc.description': [ 'dc.description': [
{ {
value: groupDescription value: groupDescription,
} },
], ],
}, },
}); });
@@ -376,7 +406,7 @@ describe('GroupFormComponent', () => {
const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{ const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> { searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected])); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected]));
} },
}); });
component.formGroup.controls.groupName.setValue('testName'); component.formGroup.controls.groupName.setValue('testName');
component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup)); component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup));
@@ -400,7 +430,7 @@ describe('GroupFormComponent', () => {
component.canEdit$ = observableOf(true); component.canEdit$ = observableOf(true);
component.groupBeingEdited = { component.groupBeingEdited = {
permanent: false permanent: false,
} as Group; } as Group;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -1,23 +1,45 @@
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core'; import {
ChangeDetectorRef,
Component,
EventEmitter,
HostListener,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import { UntypedFormGroup } 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 {
DynamicFormControlModel, DynamicFormControlModel,
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel, DynamicInputModel,
DynamicTextAreaModel DynamicTextAreaModel,
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Operation } from 'fast-json-patch';
import { import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf, of as observableOf,
Subscription, Subscription,
} from 'rxjs'; } from 'rxjs';
import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators'; import {
catchError,
debounceTime,
filter,
map,
switchMap,
take,
} from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths'; import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths'; import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
@@ -30,28 +52,32 @@ import { Group } from '../../../core/eperson/models/group.model';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { Community } from '../../../core/shared/community.model'; import { Community } from '../../../core/shared/community.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { import {
getRemoteDataPayload,
getFirstSucceededRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/alert-type'; import { AlertType } from '../../../shared/alert/alert-type';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue, isNotEmpty, hasValueOperator } from '../../../shared/empty.util'; import {
hasValue,
hasValueOperator,
isNotEmpty,
} from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
import { NoContent } from '../../../core/shared/NoContent.model'; import {
import { Operation } from 'fast-json-patch'; getGroupEditRoute,
getGroupsRoute,
} from '../../access-control-routing-paths';
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 { getGroupEditRoute, getGroupsRoute } from '../../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-group-form', selector: 'ds-group-form',
templateUrl: './group-form.component.html' templateUrl: './group-form.component.html',
}) })
/** /**
* A form used for creating and editing groups * A form used for creating and editing groups
@@ -83,13 +109,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
formLayout: DynamicFormLayout = { formLayout: DynamicFormLayout = {
groupName: { groupName: {
grid: { grid: {
host: 'row' host: 'row',
} },
}, },
groupDescription: { groupDescription: {
grid: { grid: {
host: 'row' host: 'row',
} },
}, },
}; };
@@ -176,7 +202,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
observableCombineLatest([ observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.groupName`), this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`), this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`) this.translateService.get(`${this.messagePrefix}.groupDescription`),
]).subscribe(([groupName, groupCommunity, groupDescription]) => { ]).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({ this.groupName = new DynamicInputModel({
id: 'groupName', id: 'groupName',
@@ -207,7 +233,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
]; ];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
if (!!this.formGroup.controls.groupName) { if (this.formGroup.controls.groupName) {
this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService));
this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
@@ -219,7 +245,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupDataService.getActiveGroup(), this.groupDataService.getActiveGroup(),
this.canEdit$, this.canEdit$,
this.groupDataService.getActiveGroup() this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))),
]).subscribe(([activeGroup, canEdit, linkedObject]) => { ]).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) { if (activeGroup != null) {
@@ -254,7 +280,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
} }
}, 200); }, 200);
} }
}) }),
); );
}); });
} }
@@ -282,9 +308,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
metadata: { metadata: {
'dc.description': [ 'dc.description': [
{ {
value: this.groupDescription.value value: this.groupDescription.value,
} },
] ],
}, },
}; };
if (group === null) { if (group === null) {
@@ -292,7 +318,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
} else { } else {
this.editGroup(group); this.editGroup(group);
} }
} },
); );
} }
@@ -303,7 +329,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
createNewGroup(values) { createNewGroup(values) {
const groupToCreate = Object.assign(new Group(), values); const groupToCreate = Object.assign(new Group(), values);
this.groupDataService.create(groupToCreate).pipe( this.groupDataService.create(groupToCreate).pipe(
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.created.success', { name: groupToCreate.name })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.created.success', { name: groupToCreate.name }));
@@ -332,7 +358,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
// Relevant message for group name in use // Relevant message for group name in use
this.subs.push(this.groupDataService.searchGroups(group.name, { this.subs.push(this.groupDataService.searchGroups(group.name, {
currentPage: 1, currentPage: 1,
elementsPerPage: 0 elementsPerPage: 0,
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload()) }).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.subscribe((list: PaginatedList<Group>) => { .subscribe((list: PaginatedList<Group>) => {
if (list.totalElements > 0) { if (list.totalElements > 0) {
@@ -354,7 +380,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
operations = [...operations, { operations = [...operations, {
op: 'add', op: 'add',
path: '/metadata/dc.description', path: '/metadata/dc.description',
value: this.groupDescription.value value: this.groupDescription.value,
}]; }];
} }
@@ -362,12 +388,12 @@ export class GroupFormComponent implements OnInit, OnDestroy {
operations = [...operations, { operations = [...operations, {
op: 'replace', op: 'replace',
path: '/name', path: '/name',
value: this.groupName.value value: this.groupName.value,
}]; }];
} }
this.groupDataService.patch(group, operations).pipe( this.groupDataService.patch(group, operations).pipe(
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: this.dsoNameService.getName(rd.payload) })); this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: this.dsoNameService.getName(rd.payload) }));
@@ -420,7 +446,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
delete() { delete() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = group; modalRef.componentInstance.name = this.dsoNameService.getName(group);
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header';
modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info';
modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel';
@@ -506,7 +532,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
return getCollectionEditRolesRoute(rd.payload.id); return getCollectionEditRolesRoute(rd.payload.id);
} }
} }
}) }),
); );
} }
} }

View File

@@ -1,7 +1,7 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h2 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h2>
<h4>{{messagePrefix + '.headMembers' | translate}}</h4> <h3>{{messagePrefix + '.headMembers' | translate}}</h3>
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0" <ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0"
[paginationOptions]="config" [paginationOptions]="config"
@@ -24,8 +24,7 @@
<tr *ngFor="let eperson of (ePeopleMembersOfGroup | async)?.page"> <tr *ngFor="let eperson of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{eperson.id}}</td> <td class="align-middle">{{eperson.id}}</td>
<td class="align-middle"> <td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(eperson)" <a [routerLink]="getEPersonEditRoute(eperson.id)">
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(eperson) }} {{ dsoNameService.getName(eperson) }}
</a> </a>
</td> </td>
@@ -50,12 +49,12 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(ePeopleMembersOfGroup | async) == undefined || (ePeopleMembersOfGroup | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" <div *ngIf="(ePeopleMembersOfGroup | async) === undefined || (ePeopleMembersOfGroup | async)?.totalElements === 0" class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-members-yet' | translate}} {{messagePrefix + '.no-members-yet' | translate}}
</div> </div>
<h4 id="search" class="border-bottom pb-2"> <h3 id="search" class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople', content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
@@ -66,7 +65,7 @@
> >
{{messagePrefix + '.search.head' | translate}} {{messagePrefix + '.search.head' | translate}}
</span> </span>
</h4> </h3>
<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 class="flex-grow-1 mr-3"> <div class="flex-grow-1 mr-3">
@@ -106,8 +105,7 @@
<tr *ngFor="let eperson of (ePeopleSearch | async)?.page"> <tr *ngFor="let eperson of (ePeopleSearch | async)?.page">
<td class="align-middle">{{eperson.id}}</td> <td class="align-middle">{{eperson.id}}</td>
<td class="align-middle"> <td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(eperson)" <a [routerLink]="getEPersonEditRoute(eperson.id)">
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(eperson) }} {{ dsoNameService.getName(eperson) }}
</a> </a>
</td> </td>
@@ -132,7 +130,7 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(ePeopleSearch | async)?.totalElements == 0 && searchDone" <div *ngIf="(ePeopleSearch | async)?.totalElements === 0 && searchDone"
class="alert alert-info w-100 mb-2" class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-items' | translate}} {{messagePrefix + '.no-items' | translate}}

View File

@@ -1,35 +1,68 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import {
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; DebugElement,
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; NO_ERRORS_SCHEMA,
import { BrowserModule, By } from '@angular/platform-browser'; } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
flush,
inject,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import {
import { Observable, of as observableOf } from 'rxjs'; TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { RestResponse } from '../../../../core/cache/response.models'; import { RestResponse } from '../../../../core/cache/response.models';
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';
import { EPerson } from '../../../../core/eperson/models/eperson.model'; 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 { PaginationService } from '../../../../core/pagination/pagination.service';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { GroupMock } from '../../../../shared/testing/group-mock';
import { MembersListComponent } from './members-list.component';
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { PaginationService } from '../../../../core/pagination/pagination.service';
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'; import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import {
EPersonMock,
EPersonMock2,
} from '../../../../shared/testing/eperson.mock';
import { GroupMock } from '../../../../shared/testing/group-mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { MembersListComponent } from './members-list.component';
// todo: optimize imports
describe('MembersListComponent', () => { describe('MembersListComponent', () => {
let component: MembersListComponent; let component: MembersListComponent;
@@ -68,9 +101,6 @@ describe('MembersListComponent', () => {
clearLinkRequests() { clearLinkRequests() {
// empty // empty
}, },
getEPeoplePageRouterLink(): string {
return '/access-control/epeople';
}
}; };
groupsDataServiceStub = { groupsDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
@@ -112,7 +142,7 @@ describe('MembersListComponent', () => {
// Add eperson to list of non-members // Add eperson to list of non-members
this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete]; this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete];
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
} },
}; };
builderService = getMockFormBuilderService(); builderService = getMockFormBuilderService();
translateService = getMockTranslateService(); translateService = getMockTranslateService();
@@ -123,8 +153,8 @@ describe('MembersListComponent', () => {
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock useClass: TranslateLoaderMock,
} },
}), }),
], ],
declarations: [MembersListComponent], declarations: [MembersListComponent],
@@ -137,7 +167,7 @@ describe('MembersListComponent', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: DSONameService, useValue: new DSONameServiceMock() },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -1,28 +1,41 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import {
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { UntypedFormBuilder } 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 {
BehaviorSubject,
Observable, Observable,
Subscription, Subscription,
BehaviorSubject
} from 'rxjs'; } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import {
map,
switchMap,
take,
} from 'rxjs/operators';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
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';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { EPerson } from '../../../../core/eperson/models/eperson.model'; 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 { PaginationService } from '../../../../core/pagination/pagination.service';
import { import {
getFirstCompletedRemoteData,
getAllCompletedRemoteData, getAllCompletedRemoteData,
getRemoteDataPayload getFirstCompletedRemoteData,
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 { PaginationService } from '../../../../core/pagination/pagination.service'; import { getEPersonEditRoute } from '../../../access-control-routing-paths';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
// todo: optimize imports
/** /**
* Keys to keep track of specific subscriptions * Keys to keep track of specific subscriptions
@@ -64,7 +77,7 @@ export interface EPersonListActionConfig {
@Component({ @Component({
selector: 'ds-members-list', selector: 'ds-members-list',
templateUrl: './members-list.component.html' templateUrl: './members-list.component.html',
}) })
/** /**
* The list of members in the edit group page * The list of members in the edit group page
@@ -84,7 +97,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
remove: { remove: {
css: 'btn-outline-danger', css: 'btn-outline-danger',
disabled: false, disabled: false,
icon: 'fas fa-trash-alt fa-fw' icon: 'fas fa-trash-alt fa-fw',
}, },
}; };
@@ -103,7 +116,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'sml', id: 'sml',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
/** /**
* Pagination config used to display the list of EPerson Membes of active group being edited * Pagination config used to display the list of EPerson Membes of active group being edited
@@ -111,7 +124,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'ml', id: 'ml',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
/** /**
@@ -131,6 +144,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
// current active group being edited // current active group being edited
groupBeingEdited: Group; groupBeingEdited: Group;
readonly getEPersonEditRoute = getEPersonEditRoute;
constructor( constructor(
protected groupDataService: GroupDataService, protected groupDataService: GroupDataService,
public ePersonDataService: EPersonDataService, public ePersonDataService: EPersonDataService,
@@ -152,7 +167,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
this.retrieveMembers(this.config.currentPage); this.retrieveMembers(this.config.currentPage);
this.search({query: ''}); this.search({ query: '' });
} }
})); }));
} }
@@ -170,8 +185,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
elementsPerPage: currentPagination.pageSize elementsPerPage: currentPagination.pageSize,
} },
); );
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
@@ -214,7 +229,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
// Reload search results (if there is an active query). // Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results. // This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) { if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery}); this.search({ query: this.currentSearchQuery });
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
@@ -234,7 +249,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
// Reload search results (if there is an active query). // Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results. // This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) { if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery}); this.search({ query: this.currentSearchQuery });
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
@@ -260,7 +275,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, { return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize elementsPerPage: paginationOptions.pageSize,
}, false, true); }, false, true);
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),

View File

@@ -45,7 +45,7 @@
</div> </div>
</ds-pagination> </ds-pagination>
<div *ngIf="(subGroups$ | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" <div *ngIf="(subGroups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}} {{messagePrefix + '.no-subgroups-yet' | translate}}
</div> </div>
@@ -124,7 +124,7 @@
</div> </div>
</ds-pagination> </ds-pagination>
<div *ngIf="(searchResults$ | async)?.payload?.totalElements == 0 && searchDone" class="alert alert-info w-100 mb-2" <div *ngIf="(searchResults$ | async)?.payload?.totalElements === 0 && searchDone" class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-items' | translate}} {{messagePrefix + '.no-items' | translate}}
</div> </div>

View File

@@ -1,35 +1,63 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; import {
import { ComponentFixture, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing'; DebugElement,
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; NO_ERRORS_SCHEMA,
import { BrowserModule, By } from '@angular/platform-browser'; } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
flush,
inject,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import {
import { Observable, of as observableOf } from 'rxjs'; TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { RestResponse } from '../../../../core/cache/response.models'; import { RestResponse } from '../../../../core/cache/response.models';
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 { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock';
import { SubgroupsListComponent } from './subgroups-list.component';
import {
createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { PaginationService } from '../../../../core/pagination/pagination.service';
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'; import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import {
GroupMock,
GroupMock2,
} from '../../../../shared/testing/group-mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { SubgroupsListComponent } from './subgroups-list.component';
describe('SubgroupsListComponent', () => { describe('SubgroupsListComponent', () => {
let component: SubgroupsListComponent; let component: SubgroupsListComponent;
@@ -56,7 +84,7 @@ describe('SubgroupsListComponent', () => {
}, },
subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' }, subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' },
object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' }, object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' },
epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' } epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' },
}, },
_name: 'activegroupname', _name: 'activegroupname',
id: 'activegroupid', id: 'activegroupid',
@@ -120,7 +148,7 @@ describe('SubgroupsListComponent', () => {
// Add group to list of non-members // Add group to list of non-members
this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete]; this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete];
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
} },
}; };
routerStub = new RouterMock(); routerStub = new RouterMock();
builderService = getMockFormBuilderService(); builderService = getMockFormBuilderService();
@@ -132,8 +160,8 @@ describe('SubgroupsListComponent', () => {
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock useClass: TranslateLoaderMock,
} },
}), }),
], ],
declarations: [SubgroupsListComponent], declarations: [SubgroupsListComponent],
@@ -145,7 +173,7 @@ describe('SubgroupsListComponent', () => {
{ provide: Router, useValue: routerStub }, { provide: Router, useValue: routerStub },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -1,23 +1,38 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import {
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { UntypedFormBuilder } 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, Subscription } from 'rxjs'; import {
import { map, switchMap, take } from 'rxjs/operators'; BehaviorSubject,
Observable,
Subscription,
} from 'rxjs';
import {
map,
switchMap,
take,
} from 'rxjs/operators';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
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 { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { NoContent } from '../../../../core/shared/NoContent.model';
import { import {
getAllCompletedRemoteData, getAllCompletedRemoteData,
getFirstCompletedRemoteData getFirstCompletedRemoteData,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { PageInfo } from '../../../../core/shared/page-info.model';
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 { NoContent } from '../../../../core/shared/NoContent.model';
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
@@ -30,7 +45,7 @@ enum SubKey {
@Component({ @Component({
selector: 'ds-subgroups-list', selector: 'ds-subgroups-list',
templateUrl: './subgroups-list.component.html' templateUrl: './subgroups-list.component.html',
}) })
/** /**
* The list of subgroups in the edit group page * The list of subgroups in the edit group page
@@ -49,6 +64,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
*/ */
subGroups$: BehaviorSubject<RemoteData<PaginatedList<Group>>> = new BehaviorSubject(undefined); subGroups$: BehaviorSubject<RemoteData<PaginatedList<Group>>> = new BehaviorSubject(undefined);
subGroupsPageInfoState$: Observable<PageInfo>;
/** /**
* Map of active subscriptions * Map of active subscriptions
*/ */
@@ -60,7 +77,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'ssgl', id: 'ssgl',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
/** /**
* Pagination config used to display the list of subgroups of currently active group being edited * Pagination config used to display the list of subgroups of currently active group being edited
@@ -68,7 +85,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'sgl', id: 'sgl',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
// The search form // The search form
@@ -102,9 +119,12 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
this.retrieveSubGroups(); this.retrieveSubGroups();
this.search({query: ''}); this.search({ query: '' });
} }
})); }));
this.subGroupsPageInfoState$ = this.subGroups$.pipe(
map(subGroupsRD => subGroupsRD?.payload?.pageInfo),
);
} }
/** /**
@@ -120,12 +140,12 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, {
currentPage: config.currentPage, currentPage: config.currentPage,
elementsPerPage: config.pageSize elementsPerPage: config.pageSize,
}, },
true, true,
true, true,
followLink('object') followLink('object'),
)) )),
).subscribe((rd: RemoteData<PaginatedList<Group>>) => { ).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.subGroups$.next(rd); this.subGroups$.next(rd);
})); }));
@@ -143,7 +163,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
// Reload search results (if there is an active query). // Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results. // This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) { if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery}); this.search({ query: this.currentSearchQuery });
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
@@ -164,7 +184,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
// Reload search results (if there is an active query). // Reload search results (if there is an active query).
// This will potentially remove this added subgroup from search results. // This will potentially remove this added subgroup from search results.
if (this.currentSearchQuery != null) { if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery}); this.search({ query: this.currentSearchQuery });
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
@@ -194,7 +214,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, { return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize elementsPerPage: paginationOptions.pageSize,
}, false, true, followLink('object')); }, false, true, followLink('object'));
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),

View File

@@ -1,10 +1,13 @@
import { AbstractControl, ValidationErrors } from '@angular/forms'; import {
AbstractControl,
ValidationErrors,
} from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map} from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
export class ValidateGroupExists { export class ValidateGroupExists {
@@ -17,7 +20,7 @@ export class ValidateGroupExists {
return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => { return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
return groupDataService.searchGroups(control.value, { return groupDataService.searchGroups(control.value, {
currentPage: 1, currentPage: 1,
elementsPerPage: 100 elementsPerPage: 100,
}) })
.pipe( .pipe(
getFirstSucceededRemoteListPayload(), getFirstSucceededRemoteListPayload(),

View File

@@ -1,10 +1,14 @@
import { GroupPageGuard } from './group-page.guard'; import {
import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; ActivatedRouteSnapshot,
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; Router,
import { ActivatedRouteSnapshot, Router } from '@angular/router'; } from '@angular/router';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { GroupPageGuard } from './group-page.guard';
describe('GroupPageGuard', () => { describe('GroupPageGuard', () => {
const groupsEndpointUrl = 'https://test.org/api/eperson/groups'; const groupsEndpointUrl = 'https://test.org/api/eperson/groups';
@@ -13,7 +17,7 @@ describe('GroupPageGuard', () => {
const routeSnapshotWithGroupId = { const routeSnapshotWithGroupId = {
params: { params: {
groupId: groupUuid, groupId: groupUuid,
} },
} as unknown as ActivatedRouteSnapshot; } as unknown as ActivatedRouteSnapshot;
let guard: GroupPageGuard; let guard: GroupPageGuard;
@@ -50,10 +54,10 @@ describe('GroupPageGuard', () => {
it('should return true', (done) => { it('should return true', (done) => {
guard.canActivate( guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url'} as any routeSnapshotWithGroupId, { url: 'current-url' } as any,
).subscribe((result) => { ).subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith( expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined FeatureID.CanManageGroup, groupEndpointUrl, undefined,
); );
expect(result).toBeTrue(); expect(result).toBeTrue();
done(); done();
@@ -68,10 +72,10 @@ describe('GroupPageGuard', () => {
it('should not return true', (done) => { it('should not return true', (done) => {
guard.canActivate( guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url'} as any routeSnapshotWithGroupId, { url: 'current-url' } as any,
).subscribe((result) => { ).subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith( expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined FeatureID.CanManageGroup, groupEndpointUrl, undefined,
); );
expect(result).not.toBeTrue(); expect(result).not.toBeTrue();
done(); done();

View File

@@ -1,15 +1,23 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import {
import { Observable, of as observableOf } from 'rxjs'; ActivatedRouteSnapshot,
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; Router,
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; RouterStateSnapshot,
import { AuthService } from '../../core/auth/auth.service'; } from '@angular/router';
import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard'; import {
import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; Observable,
of as observableOf,
} from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class GroupPageGuard extends SomeFeatureAuthorizationGuard { export class GroupPageGuard extends SomeFeatureAuthorizationGuard {
@@ -28,7 +36,7 @@ export class GroupPageGuard extends SomeFeatureAuthorizationGuard {
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe( return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe(
map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`) map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`),
); );
} }

View File

@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Group } from '../../core/eperson/models/group.model'; import { Group } from '../../core/eperson/models/group.model';
import { type } from '../../shared/ngrx/type'; import { type } from '../../shared/ngrx/type';

View File

@@ -1,6 +1,12 @@
import { GroupMock } from '../../shared/testing/group-mock'; import { GroupMock } from '../../shared/testing/group-mock';
import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction } from './group-registry.actions'; import {
import { groupRegistryReducer, GroupRegistryState } from './group-registry.reducers'; GroupRegistryCancelGroupAction,
GroupRegistryEditGroupAction,
} from './group-registry.actions';
import {
groupRegistryReducer,
GroupRegistryState,
} from './group-registry.reducers';
const initialState: GroupRegistryState = { const initialState: GroupRegistryState = {
editGroup: null, editGroup: null,

View File

@@ -1,5 +1,9 @@
import { Group } from '../../core/eperson/models/group.model'; import { Group } from '../../core/eperson/models/group.model';
import { GroupRegistryAction, GroupRegistryActionTypes, GroupRegistryEditGroupAction } from './group-registry.actions'; import {
GroupRegistryAction,
GroupRegistryActionTypes,
GroupRegistryEditGroupAction,
} from './group-registry.actions';
/** /**
* The metadata registry state. * The metadata registry state.
@@ -27,13 +31,13 @@ export function groupRegistryReducer(state = initialState, action: GroupRegistry
case GroupRegistryActionTypes.EDIT_GROUP: { case GroupRegistryActionTypes.EDIT_GROUP: {
return Object.assign({}, state, { return Object.assign({}, state, {
editGroup: (action as GroupRegistryEditGroupAction).group editGroup: (action as GroupRegistryEditGroupAction).group,
}); });
} }
case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: { case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: {
return Object.assign({}, state, { return Object.assign({}, state, {
editGroup: null editGroup: null,
}); });
} }

View File

@@ -2,7 +2,7 @@
<div class="groups-registry row"> <div class="groups-registry row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h2> <h1 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h1>
<div> <div>
<button class="mr-auto btn btn-success" <button class="mr-auto btn btn-success"
[routerLink]="'create'"> [routerLink]="'create'">
@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<h3 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h3> <h2 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h2>
<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 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">
@@ -35,7 +35,7 @@
<ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading> <ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading>
<ds-pagination <ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)" *ngIf="(pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="pageInfoState$" [pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements" [collectionSize]="(pageInfoState$ | async)?.totalElements"
@@ -91,7 +91,7 @@
</div> </div>
</ds-pagination> </ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> <div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
{{messagePrefix + 'no-items' | translate}} {{messagePrefix + 'no-items' | translate}}
</div> </div>

View File

@@ -1,39 +1,71 @@
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, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; ComponentFixture,
import { BrowserModule, By } from '@angular/platform-browser'; fakeAsync,
inject,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
BrowserModule,
By,
} from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import {
import { Observable, of as observableOf } from 'rxjs'; TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import {
buildPaginatedList,
PaginatedList,
} from '../../core/data/paginated-list.model';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../core/eperson/group-data.service'; import { GroupDataService } from '../../core/eperson/group-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; 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 { PaginationService } from '../../core/pagination/pagination.service';
import { RouteService } from '../../core/services/route.service'; import { RouteService } from '../../core/services/route.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { PageInfo } from '../../core/shared/page-info.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../shared/testing/group-mock';
import { GroupsRegistryComponent } from './groups-registry.component';
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { TranslateLoaderMock } from '../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { routeServiceStub } from '../../shared/testing/route-service.stub';
import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { NoContent } from '../../core/shared/NoContent.model'; import { NoContent } from '../../core/shared/NoContent.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { PageInfo } from '../../core/shared/page-info.model';
import { DSONameServiceMock, UNDEFINED_NAME } from '../../shared/mocks/dso-name.service.mock'; import {
DSONameServiceMock,
UNDEFINED_NAME,
} from '../../shared/mocks/dso-name.service.mock';
import { RouterMock } from '../../shared/mocks/router.mock';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
EPersonMock,
EPersonMock2,
} from '../../shared/testing/eperson.mock';
import {
GroupMock,
GroupMock2,
} from '../../shared/testing/group-mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { routeServiceStub } from '../../shared/testing/route-service.stub';
import { TranslateLoaderMock } from '../../shared/testing/translate-loader.mock';
import { GroupsRegistryComponent } from './groups-registry.component';
describe('GroupsRegistryComponent', () => { describe('GroupsRegistryComponent', () => {
let component: GroupsRegistryComponent; let component: GroupsRegistryComponent;
@@ -78,24 +110,24 @@ describe('GroupsRegistryComponent', () => {
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 0, totalElements: 0,
totalPages: 0, totalPages: 0,
currentPage: 1 currentPage: 1,
}), [])); }), []));
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 1, totalElements: 1,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), [EPersonMock])); }), [EPersonMock]));
default: default:
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 0, totalElements: 0,
totalPages: 0, totalPages: 0,
currentPage: 1 currentPage: 1,
}), [])); }), []));
} }
} },
}; };
groupsDataServiceStub = { groupsDataServiceStub = {
allGroups: mockGroups, allGroups: mockGroups,
@@ -106,21 +138,21 @@ describe('GroupsRegistryComponent', () => {
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 0, totalElements: 0,
totalPages: 0, totalPages: 0,
currentPage: 1 currentPage: 1,
}), [])); }), []));
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 1, totalElements: 1,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), [GroupMock2])); }), [GroupMock2]));
default: default:
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
elementsPerPage: 1, elementsPerPage: 1,
totalElements: 0, totalElements: 0,
totalPages: 0, totalPages: 0,
currentPage: 1 currentPage: 1,
}), [])); }), []));
} }
}, },
@@ -136,7 +168,7 @@ describe('GroupsRegistryComponent', () => {
elementsPerPage: this.allGroups.length, elementsPerPage: this.allGroups.length,
totalElements: this.allGroups.length, totalElements: this.allGroups.length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), this.allGroups)); }), this.allGroups));
} }
const result = this.allGroups.find((group: Group) => { const result = this.allGroups.find((group: Group) => {
@@ -146,7 +178,7 @@ describe('GroupsRegistryComponent', () => {
elementsPerPage: [result].length, elementsPerPage: [result].length,
totalElements: [result].length, totalElements: [result].length,
totalPages: 1, totalPages: 1,
currentPage: 1 currentPage: 1,
}), [result])); }), [result]));
}, },
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> { delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
@@ -156,7 +188,7 @@ describe('GroupsRegistryComponent', () => {
dsoDataServiceStub = { dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> { findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return createSuccessfulRemoteDataObject$(undefined); return createSuccessfulRemoteDataObject$(undefined);
} },
}; };
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']); authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
@@ -167,8 +199,8 @@ describe('GroupsRegistryComponent', () => {
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock useClass: TranslateLoaderMock,
} },
}), }),
], ],
declarations: [GroupsRegistryComponent], declarations: [GroupsRegistryComponent],
@@ -182,9 +214,9 @@ describe('GroupsRegistryComponent', () => {
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) } { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));
@@ -237,16 +269,16 @@ describe('GroupsRegistryComponent', () => {
it('should not check the canManageGroup permissions', () => { it('should not check the canManageGroup permissions', () => {
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith( expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[0].self FeatureID.CanManageGroup, mockGroups[0].self,
); );
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith( expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[0].self, undefined // treated differently FeatureID.CanManageGroup, mockGroups[0].self, undefined, // treated differently
); );
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith( expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[1].self FeatureID.CanManageGroup, mockGroups[1].self,
); );
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith( expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[1].self, undefined // treated differently FeatureID.CanManageGroup, mockGroups[1].self, undefined, // treated differently
); );
}); });
}); });

View File

@@ -1,4 +1,8 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import {
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { UntypedFormBuilder } 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';
@@ -8,36 +12,46 @@ import {
EMPTY, EMPTY,
Observable, Observable,
of as observableOf, of as observableOf,
Subscription Subscription,
} from 'rxjs'; } from 'rxjs';
import { catchError, defaultIfEmpty, map, switchMap, tap } from 'rxjs/operators'; import {
catchError,
defaultIfEmpty,
map,
switchMap,
tap,
} from 'rxjs/operators';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
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 { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../core/eperson/group-data.service'; import { GroupDataService } from '../../core/eperson/group-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { GroupDtoModel } from '../../core/eperson/models/group-dto.model';
import { Group } from '../../core/eperson/models/group.model'; import { Group } from '../../core/eperson/models/group.model';
import { GroupDtoModel } from '../../core/eperson/models/group-dto.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { RouteService } from '../../core/services/route.service'; import { RouteService } from '../../core/services/route.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { import {
getAllSucceededRemoteData, getAllSucceededRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getRemoteDataPayload getRemoteDataPayload,
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
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 { NoContent } from '../../core/shared/NoContent.model';
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',
@@ -57,7 +71,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'gl', id: 'gl',
pageSize: 5, pageSize: 5,
currentPage: 1 currentPage: 1,
}); });
/** /**
@@ -133,7 +147,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
const query: string = data.query; const query: string = data.query;
if (query != null && this.currentSearchQuery !== query) { if (query != null && this.currentSearchQuery !== query) {
this.currentSearchQuery = query; this.currentSearchQuery = query;
this.paginationService.updateRouteWithUrl(this.config.id, [], {page: 1}); this.paginationService.updateRouteWithUrl(this.config.id, [], { page: 1 });
} }
return this.groupService.searchGroups(this.currentSearchQuery.trim(), { return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
@@ -155,7 +169,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
this.canManageGroup$(isSiteAdmin, group), this.canManageGroup$(isSiteAdmin, group),
this.hasLinkedDSO(group), this.hasLinkedDSO(group),
this.getSubgroups(group), this.getSubgroups(group),
this.getMembers(group) this.getMembers(group),
]).pipe( ]).pipe(
map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]: map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]:
[boolean, boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => { [boolean, boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
@@ -166,8 +180,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
groupDtoModel.subgroups = subgroups.payload; groupDtoModel.subgroups = subgroups.payload;
groupDtoModel.epersons = members.payload; groupDtoModel.epersons = members.payload;
return groupDtoModel; return groupDtoModel;
} },
) ),
); );
} else { } else {
return EMPTY; return EMPTY;
@@ -175,9 +189,9 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
})]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => { })]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => {
return buildPaginatedList(groups.pageInfo, dtos); return buildPaginatedList(groups.pageInfo, dtos);
})); }));
}) }),
); );
}) }),
).subscribe((value: PaginatedList<GroupDtoModel>) => { ).subscribe((value: PaginatedList<GroupDtoModel>) => {
this.groupsDto$.next(value); this.groupsDto$.next(value);
this.pageInfoState$.next(value.pageInfo); this.pageInfoState$.next(value.pageInfo);

View File

@@ -1,4 +1,4 @@
<div class="container"> <div class="container">
<h2>{{'admin.curation-tasks.header' |translate }}</h2> <h1>{{'admin.curation-tasks.header' |translate }}</h1>
<ds-curation-form></ds-curation-form> <ds-curation-form></ds-curation-form>
</div> </div>

View File

@@ -1,7 +1,12 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AdminCurationTasksComponent } from './admin-curation-tasks.component';
import { TranslateModule } from '@ngx-translate/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { AdminCurationTasksComponent } from './admin-curation-tasks.component';
describe('AdminCurationTasksComponent', () => { describe('AdminCurationTasksComponent', () => {
let comp: AdminCurationTasksComponent; let comp: AdminCurationTasksComponent;
@@ -11,7 +16,7 @@ describe('AdminCurationTasksComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [AdminCurationTasksComponent], declarations: [AdminCurationTasksComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h2 id="header">{{'admin.batch-import.page.header' | translate}}</h2> <h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1>
<p>{{'admin.batch-import.page.help' | translate}}</p> <p>{{'admin.batch-import.page.help' | translate}}</p>
<p *ngIf="dso"> <p *ngIf="dso">
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp; selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;

View File

@@ -1,22 +1,31 @@
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { BatchImportPageComponent } from './batch-import-page.component';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive';
import { FileValidator } from '../../shared/utils/require-file.validator';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import {
BATCH_IMPORT_SCRIPT_NAME,
ScriptDataService
} from '../../core/data/processes/script-data.service';
import { Router } from '@angular/router';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
BATCH_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../core/data/processes/script-data.service';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive';
import { FileValidator } from '../../shared/utils/require-file.validator';
import { BatchImportPageComponent } from './batch-import-page.component';
describe('BatchImportPageComponent', () => { describe('BatchImportPageComponent', () => {
let component: BatchImportPageComponent; let component: BatchImportPageComponent;
@@ -31,14 +40,14 @@ describe('BatchImportPageComponent', () => {
notificationService = new NotificationsServiceStub(); notificationService = new NotificationsServiceStub();
scriptService = jasmine.createSpyObj('scriptService', scriptService = jasmine.createSpyObj('scriptService',
{ {
invoke: createSuccessfulRemoteDataObject$({ processId: '46' }) invoke: createSuccessfulRemoteDataObject$({ processId: '46' }),
} },
); );
router = jasmine.createSpyObj('router', { router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl') navigateByUrl: jasmine.createSpy('navigateByUrl'),
}); });
locationStub = jasmine.createSpyObj('location', { locationStub = jasmine.createSpyObj('location', {
back: jasmine.createSpy('back') back: jasmine.createSpy('back'),
}); });
} }
@@ -48,7 +57,7 @@ describe('BatchImportPageComponent', () => {
imports: [ imports: [
FormsModule, FormsModule,
TranslateModule.forRoot(), TranslateModule.forRoot(),
RouterTestingModule.withRoutes([]) RouterTestingModule.withRoutes([]),
], ],
declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator], declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator],
providers: [ providers: [
@@ -57,7 +66,7 @@ describe('BatchImportPageComponent', () => {
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
{ provide: Location, useValue: locationStub }, { provide: Location, useValue: locationStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));
@@ -108,7 +117,7 @@ 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: '--add' }), Object.assign(new ProcessParameter(), { name: '--add' }),
Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }) Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }),
]; ];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]);
}); });
@@ -181,7 +190,7 @@ describe('BatchImportPageComponent', () => {
it('metadata-import script is invoked with --url and the file url', () => { it('metadata-import script is invoked with --url and the file url', () => {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--add' }), Object.assign(new ProcessParameter(), { name: '--add' }),
Object.assign(new ProcessParameter(), { name: '--url', value: 'example.fileURL.com' }) Object.assign(new ProcessParameter(), { name: '--url', value: 'example.fileURL.com' }),
]; ];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [null]); expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [null]);
}); });

View File

@@ -1,26 +1,31 @@
import { Component } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { TranslateService } from '@ngx-translate/core'; import { Component } from '@angular/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { RemoteData } from '../../core/data/remote-data';
import { Process } from '../../process-page/processes/process.model';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
import {
ImportBatchSelectorComponent
} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import {
BATCH_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../core/data/processes/script-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
import { Process } from '../../process-page/processes/process.model';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { ImportBatchSelectorComponent } from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
import {
isEmpty,
isNotEmpty,
} from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@Component({ @Component({
selector: 'ds-batch-import-page', selector: 'ds-batch-import-page',
templateUrl: './batch-import-page.component.html' templateUrl: './batch-import-page.component.html',
}) })
export class BatchImportPageComponent { export class BatchImportPageComponent {
/** /**
@@ -91,7 +96,7 @@ export class BatchImportPageComponent {
} }
} else { } else {
const parameterValues: ProcessParameter[] = [ const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--add' }) Object.assign(new ProcessParameter(), { name: '--add' }),
]; ];
if (this.isUpload) { if (this.isUpload) {
parameterValues.push(Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name })); parameterValues.push(Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }));

View File

@@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h2 id="header">{{'admin.metadata-import.page.header' | translate}}</h2> <h1 id="header">{{'admin.metadata-import.page.header' | translate}}</h1>
<p>{{'admin.metadata-import.page.help' | translate}}</p> <p>{{'admin.metadata-import.page.help' | translate}}</p>
<div class="form-group"> <div class="form-group">
<div class="form-check"> <div class="form-check">

View File

@@ -1,19 +1,31 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import {
ComponentFixture,
fakeAsync,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
import {
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../core/data/processes/script-data.service';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive';
import { FileValidator } from '../../shared/utils/require-file.validator'; import { FileValidator } from '../../shared/utils/require-file.validator';
import { MetadataImportPageComponent } from './metadata-import-page.component'; import { MetadataImportPageComponent } from './metadata-import-page.component';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
describe('MetadataImportPageComponent', () => { describe('MetadataImportPageComponent', () => {
let comp: MetadataImportPageComponent; let comp: MetadataImportPageComponent;
@@ -28,14 +40,14 @@ describe('MetadataImportPageComponent', () => {
notificationService = new NotificationsServiceStub(); notificationService = new NotificationsServiceStub();
scriptService = jasmine.createSpyObj('scriptService', scriptService = jasmine.createSpyObj('scriptService',
{ {
invoke: createSuccessfulRemoteDataObject$({ processId: '45' }) invoke: createSuccessfulRemoteDataObject$({ processId: '45' }),
} },
); );
router = jasmine.createSpyObj('router', { router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl') navigateByUrl: jasmine.createSpy('navigateByUrl'),
}); });
locationStub = jasmine.createSpyObj('location', { locationStub = jasmine.createSpyObj('location', {
back: jasmine.createSpy('back') back: jasmine.createSpy('back'),
}); });
} }
@@ -45,7 +57,7 @@ describe('MetadataImportPageComponent', () => {
imports: [ imports: [
FormsModule, FormsModule,
TranslateModule.forRoot(), TranslateModule.forRoot(),
RouterTestingModule.withRoutes([]) RouterTestingModule.withRoutes([]),
], ],
declarations: [MetadataImportPageComponent, FileValueAccessorDirective, FileValidator], declarations: [MetadataImportPageComponent, FileValueAccessorDirective, FileValidator],
providers: [ providers: [
@@ -54,7 +66,7 @@ describe('MetadataImportPageComponent', () => {
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
{ provide: Location, useValue: locationStub }, { provide: Location, useValue: locationStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -2,18 +2,22 @@ import { Location } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
import {
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService,
} from '../../core/data/processes/script-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
import { Process } from '../../process-page/processes/process.model';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteData } from '../../core/data/remote-data';
import { Process } from '../../process-page/processes/process.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths';
@Component({ @Component({
selector: 'ds-metadata-import-page', selector: 'ds-metadata-import-page',
templateUrl: './metadata-import-page.component.html' templateUrl: './metadata-import-page.component.html',
}) })
/** /**

View File

@@ -0,0 +1,50 @@
import { NgModule } from '@angular/core';
import {
RouterModule,
Routes,
} from '@angular/router';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { NavigationBreadcrumbResolver } from '../../core/breadcrumbs/navigation-breadcrumb.resolver';
import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component';
import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component';
const moduleRoutes: Routes = [
{
path: '',
pathMatch: 'full',
component: LdnServicesOverviewComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'ldn-registered-services.title', breadcrumbKey: 'ldn-registered-services.new' },
},
{
path: 'new',
resolve: { breadcrumb: NavigationBreadcrumbResolver },
component: LdnServiceFormComponent,
data: { title: 'ldn-register-new-service.title', breadcrumbKey: 'ldn-register-new-service' },
},
{
path: 'edit/:serviceId',
resolve: { breadcrumb: NavigationBreadcrumbResolver },
component: LdnServiceFormComponent,
data: { title: 'ldn-edit-service.title', breadcrumbKey: 'ldn-edit-service' },
},
];
@NgModule({
imports: [
RouterModule.forChild(moduleRoutes.map(route => {
return { ...route, data: {
...route.data,
relatedRoutes: moduleRoutes.filter(relatedRoute => relatedRoute.path !== route.path)
.map((relatedRoute) => {
return { path: relatedRoute.path, data: relatedRoute.data };
}),
} };
})),
],
})
export class AdminLdnServicesRoutingModule {
}

View File

@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../shared/shared.module';
import { AdminLdnServicesRoutingModule } from './admin-ldn-services-routing.module';
import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component';
import { LdnItemfiltersService } from './ldn-services-data/ldn-itemfilters-data.service';
import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
AdminLdnServicesRoutingModule,
FormsModule,
],
declarations: [
LdnServicesOverviewComponent,
LdnServiceFormComponent,
],
providers: [LdnItemfiltersService],
})
export class AdminLdnServicesModule {
}

View File

@@ -0,0 +1,315 @@
<div class="container">
<form (ngSubmit)="onSubmit()" [formGroup]="formModel">
<div class="d-flex">
<h1 class="flex-grow-1">{{ isNewService ? ('ldn-create-service.title' | translate) : ('ldn-edit-registered-service.title' | translate) }}</h1>
</div>
<!-- In the toggle section -->
<div class="toggle-switch-container" *ngIf="!isNewService">
<label class="status-label font-weight-bold" for="enabled">{{ 'ldn-service-status' | translate }}</label>
<div>
<input formControlName="enabled" hidden id="enabled" name="enabled" type="checkbox">
<div (click)="toggleEnabled()" [class.checked]="formModel.get('enabled').value" class="toggle-switch">
<div class="slider"></div>
</div>
</div>
</div>
<!-- In the Name section -->
<div class="mb-5">
<label for="name" class="font-weight-bold">{{ 'ldn-new-service.form.label.name' | translate }}</label>
<input [class.invalid-field]="formModel.get('name').invalid && formModel.get('name').touched"
[placeholder]="'ldn-new-service.form.placeholder.name' | translate" class="form-control"
formControlName="name"
id="name"
name="name"
type="text">
<div *ngIf="formModel.get('name').invalid && formModel.get('name').touched" class="error-text">
{{ 'ldn-new-service.form.error.name' | translate }}
</div>
</div>
<!-- In the description section -->
<div class="mb-5 mt-5 d-flex flex-column">
<label for="description" class="font-weight-bold">{{ 'ldn-new-service.form.label.description' | translate }}</label>
<textarea [placeholder]="'ldn-new-service.form.placeholder.description' | translate"
class="form-control" formControlName="description" id="description" name="description"></textarea>
</div>
<div class="mb-5 mt-5">
<!-- In the url section -->
<div class="d-flex align-items-center">
<div class="d-flex flex-column w-50 mr-2">
<label for="url" class="font-weight-bold">{{ 'ldn-new-service.form.label.url' | translate }}</label>
<input [class.invalid-field]="formModel.get('url').invalid && formModel.get('url').touched"
[placeholder]="'ldn-new-service.form.placeholder.url' | translate" class="form-control"
formControlName="url"
id="url"
name="url"
type="text">
<div *ngIf="formModel.get('url').invalid && formModel.get('url').touched" class="error-text">
{{ 'ldn-new-service.form.error.url' | translate }}
</div>
</div>
<div class="d-flex flex-column w-50">
<label for="score" class="font-weight-bold">{{ 'ldn-new-service.form.label.score' | translate }}</label>
<input [class.invalid-field]="formModel.get('score').invalid && formModel.get('score').touched"
[placeholder]="'ldn-new-service.form.placeholder.score' | translate" formControlName="score"
id="score"
name="score"
min="0"
max="1"
step=".01"
class="form-control"
type="number">
<div *ngIf="formModel.get('score').invalid && formModel.get('score').touched" class="error-text">
{{ 'ldn-new-service.form.error.score' | translate }}
</div>
</div>
</div>
</div>
<!-- In the IP range section -->
<div class="mb-5 mt-5">
<label for="lowerIp" class="font-weight-bold">{{ 'ldn-new-service.form.label.ip-range' | translate }}</label>
<div class="d-flex">
<input [class.invalid-field]="formModel.get('lowerIp').invalid && formModel.get('lowerIp').touched"
[placeholder]="'ldn-new-service.form.placeholder.lowerIp' | translate" class="form-control mr-2"
formControlName="lowerIp"
id="lowerIp"
name="lowerIp"
type="text">
<input [class.invalid-field]="formModel.get('upperIp').invalid && formModel.get('upperIp').touched"
[placeholder]="'ldn-new-service.form.placeholder.upperIp' | translate" class="form-control"
formControlName="upperIp"
id="upperIp"
name="upperIp"
type="text">
</div>
<div *ngIf="(formModel.get('lowerIp').invalid && formModel.get('lowerIp').touched) || (formModel.get('upperIp').invalid && formModel.get('upperIp').touched)" class="error-text">
{{ 'ldn-new-service.form.error.ipRange' | translate }}
</div>
<div class="text-muted">
{{ 'ldn-new-service.form.hint.ipRange' | translate }}
</div>
</div>
<!-- In the ldnUrl section -->
<div class="mb-5 mt-5">
<label for="ldnUrl" class="font-weight-bold">{{ 'ldn-new-service.form.label.ldnUrl' | translate }}</label>
<input [class.invalid-field]="formModel.get('ldnUrl').invalid && formModel.get('ldnUrl').touched"
[placeholder]="'ldn-new-service.form.placeholder.ldnUrl' | translate" class="form-control"
formControlName="ldnUrl"
id="ldnUrl"
name="ldnUrl"
type="text">
<div *ngIf="formModel.get('ldnUrl').invalid && formModel.get('ldnUrl').touched" >
<div *ngIf="formModel.get('ldnUrl').errors['required']" class="error-text">
{{ 'ldn-new-service.form.error.ldnurl' | translate }}
</div>
<div *ngIf="formModel.get('ldnUrl').errors['ldnUrlAlreadyAssociated']" class="error-text">
{{ 'ldn-new-service.form.error.ldnurl.ldnUrlAlreadyAssociated' | translate }}
</div>
</div>
</div>
<!-- In the Inbound Patterns Labels section -->
<div class="row mb-1 mt-5" *ngIf="areControlsInitialized">
<div class="col">
<label class="font-weight-bold">{{ 'ldn-new-service.form.label.inboundPattern' | translate }} </label>
</div>
<ng-container *ngIf="formModel.get('notifyServiceInboundPatterns')['controls'][0]?.value?.pattern">
<div class="col">
<label class="font-weight-bold">{{ 'ldn-new-service.form.label.ItemFilter' | translate }}</label>
</div>
<div class="col-sm-1">
<label class="font-weight-bold">{{ 'ldn-new-service.form.label.automatic' | translate }}</label>
</div>
</ng-container>
<div class="col-sm-2">
</div>
</div>
<!-- In the Inbound Patterns section -->
<div *ngIf="areControlsInitialized">
<div *ngFor="let patternGroup of formModel.get('notifyServiceInboundPatterns')['controls']; let i = index"
[class.marked-for-deletion]="markedForDeletionInboundPattern.includes(i)"
formGroupName="notifyServiceInboundPatterns">
<ng-container [formGroupName]="i">
<div class="row mb-1 align-items-center">
<div class="col">
<div #inboundPatternDropdown="ngbDropdown" class="w-80" display="dynamic"
id="additionalInboundPattern{{i}}"
ngbDropdown placement="top-start">
<div class="position-relative right-addon" role="combobox" aria-expanded="false" aria-controls="inboundPatternDropdownButton">
<i aria-hidden="true" class="position-absolute scrollable-dropdown-toggle"
ngbDropdownToggle></i>
<input
(click)="inboundPatternDropdown.open();"
[readonly]="true"
[value]="selectedInboundPatterns"
class="form-control w-80 scrollable-dropdown-input"
formControlName="patternLabel"
id="inboundPatternDropdownButton"
ngbDropdownAnchor
type="text"
[attr.aria-label]="'ldn-service-input-inbound-pattern-dropdown' | translate"
/>
<div aria-labelledby="inboundPatternDropdownButton"
class="dropdown-menu dropdown-menu-top w-100 "
ngbDropdownMenu>
<div class="scrollable-menu" role="listbox">
<button (click)="selectInboundPattern(pattern, i); $event.stopPropagation()"
*ngFor="let pattern of inboundPatterns; let internalIndex = index"
[title]="'ldn-service.form.pattern.' + pattern + '.description' | translate"
class="dropdown-item collection-item text-truncate w-100"
ngbDropdownItem
type="button">
<div>{{ 'ldn-service.form.pattern.' + pattern + '.label' | translate }}</div>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<ng-container
*ngIf="formModel.get('notifyServiceInboundPatterns')['controls'][i].value.pattern">
<div #inboundItemfilterDropdown="ngbDropdown" class="w-100" id="constraint{{i}}" ngbDropdown
placement="top-start">
<div class="position-relative right-addon" aria-expanded="false" aria-controls="inboundItemfilterDropdown" role="combobox">
<i aria-hidden="true" class="position-absolute scrollable-dropdown-toggle"
ngbDropdownToggle></i>
<input
[readonly]="true"
class="form-control d-none w-100 scrollable-dropdown-input"
formControlName="constraint"
id="inboundItemfilterDropdown"
ngbDropdownAnchor
type="text"
[attr.aria-label]="'ldn-service-input-inbound-item-filter-dropdown' | translate"
/>
<input
(click)="inboundItemfilterDropdown.open();"
[readonly]="true"
class="form-control w-100 scrollable-dropdown-input"
formControlName="constraintFormatted"
id="inboundItemfilterDropdownPrettified"
ngbDropdownAnchor
type="text"
[attr.aria-label]="'ldn-service-input-inbound-item-filter-dropdown' | translate"
/>
<div aria-labelledby="inboundItemfilterDropdownButton"
class="dropdown-menu scrollable-dropdown-menu w-100 "
ngbDropdownMenu>
<div class="scrollable-menu" role="listbox">
<button (click)="selectInboundItemFilter('', i); $event.stopPropagation()"
class="dropdown-item collection-item text-truncate w-100" ngbDropdownItem type="button">
<span> {{'ldn-service.control-constaint-select-none' | translate}} </span>
</button>
<button (click)="selectInboundItemFilter(constraint.id, i); $event.stopPropagation()"
*ngFor="let constraint of (itemfiltersRD$ | async)?.payload?.page; let internalIndex = index"
class="dropdown-item collection-item text-truncate w-100"
ngbDropdownItem
type="button">
<div>{{ constraint.id + '.label' | translate }}</div>
</button>
</div>
</div>
</div>
</div>
</ng-container>
</div>
<div
[style.visibility]="formModel.get('notifyServiceInboundPatterns')['controls'][i].value.pattern ? 'visible' : 'hidden'"
class="col-sm-1">
<input formControlName="automatic" hidden id="automatic{{i}}" name="automatic{{i}}"
type="checkbox">
<div (click)="toggleAutomatic(i)"
[class.checked]="formModel.get('notifyServiceInboundPatterns.' + i + '.automatic').value"
class="toggle-switch">
<div class="slider"></div>
</div>
</div>
<div class="col-sm-2">
<div class="btn-group">
<button (click)="markForInboundPatternDeletion(i)" class="btn btn-outline-dark trash-button"
[title]="'ldn-service-button-mark-inbound-deletion' | translate"
type="button">
<i class="fas fa-trash"></i>
</button>
<button (click)="unmarkForInboundPatternDeletion(i)"
*ngIf="markedForDeletionInboundPattern.includes(i)"
[title]="'ldn-service-button-unmark-inbound-deletion' | translate"
class="btn btn-warning "
type="button">
<i class="fas fa-undo"></i>
</button>
</div>
</div>
</div>
</ng-container>
</div>
</div>
<span (click)="addInboundPattern()"
class="add-pattern-link mb-2">{{ 'ldn-new-service.form.label.addPattern' | translate }}</span>
<div class="submission-form-footer my-1 position-sticky d-flex justify-content-between" role="group">
<button (click)="resetFormAndLeave()" class="btn btn-primary" type="button">
<span>&nbsp;{{ 'submission.general.back.submit' | translate }}</span>
</button>
<button class="btn btn-primary" type="submit">
<span><i class="fas fa-save"></i>&nbsp;{{ 'ldn-new-service.form.label.submit' | translate }}</span>
</button>
</div>
</form>
</div>
<ng-template #confirmModal>
<div class="modal-header">
<h4 *ngIf="!isNewService">{{'service.overview.edit.modal' | translate }}</h4>
<h4 *ngIf="isNewService">{{'service.overview.create.modal' | translate }}</h4>
<button (click)="closeModal()" aria-label="Close"
class="close" type="button">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div *ngIf="!isNewService">
{{ 'service.overview.edit.body' | translate }}
</div>
<span *ngIf="isNewService">
{{ 'service.overview.create.body' | translate }}
</span>
</div>
<div class="modal-footer">
<div *ngIf="!isNewService">
<button (click)="closeModal()" class="btn btn-danger mr-2"
id="delete-confirm-edit">{{ 'service.detail.return' | translate }}
</button>
<button *ngIf="!isNewService" (click)="patchService()"
class="btn btn-primary">{{ 'service.detail.update' | translate }}
</button>
</div>
<div *ngIf="isNewService">
<button (click)="closeModal()" class="btn btn-danger mr-2 "
id="delete-confirm-new">{{ 'service.refuse.create' | translate }}
</button>
<button (click)="createService()"
class="btn btn-primary">{{ 'service.confirm.create' | translate }}
</button>
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,143 @@
@import '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss';
@import '../../../shared/form/form.component.scss';
form {
font-size: 14px;
position: relative;
}
input,
select {
max-width: 100%;
width: 100%;
padding: 8px;
font-size: 14px;
}
option:not(:first-child) {
font-weight: bold;
}
.trash-button {
width: 40px;
height: 40px;
}
textarea {
height: 200px;
resize: none;
}
.add-pattern-link {
color: #0048ff;
cursor: pointer;
}
.remove-pattern-link {
color: #e34949;
cursor: pointer;
margin-left: 10px;
}
.status-checkbox {
margin-top: 5px;
}
.invalid-field {
border: 1px solid red;
color: #000000;
}
.error-text {
color: red;
font-size: 0.8em;
margin-top: 5px;
}
.toggle-switch {
display: flex;
align-items: center;
opacity: 0.8;
position: relative;
width: 60px;
height: 30px;
background-color: #ccc;
border-radius: 15px;
cursor: pointer;
transition: background-color 0.3s;
}
.toggle-switch.checked {
background-color: #24cc9a;
}
.slider {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.3s;
}
.toggle-switch .slider {
width: 22px;
height: 22px;
border-radius: 50%;
margin: 0 auto;
}
.toggle-switch.checked .slider {
transform: translateX(30px);
}
.toggle-switch-container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-end;
margin-top: 10px;
}
.small-text {
font-size: 0.7em;
color: #888;
}
.toggle-switch {
cursor: pointer;
margin-top: 3px;
margin-right: 3px
}
.label-box {
margin-left: 11px;
}
.label-box-2 {
margin-left: 14px;
}
.label-box-3 {
margin-left: 5px;
}
.submission-form-footer {
border-radius: var(--bs-card-border-radius);
bottom: 0;
background-color: var(--bs-gray-400);
padding: calc(var(--bs-spacer) / 2);
z-index: calc(var(--ds-submission-footer-z-index) + 1);
}
.marked-for-deletion {
background-color: lighten($red, 30%);
}
.dropdown-menu-top, .scrollable-dropdown-menu {
z-index: var(--ds-submission-footer-z-index);
}

View File

@@ -0,0 +1,267 @@
import {
ChangeDetectorRef,
EventEmitter,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import {
FormArray,
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import {
NgbDropdownModule,
NgbModal,
} from '@ng-bootstrap/ng-bootstrap';
import { provideMockStore } from '@ngrx/store/testing';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { PaginationService } from 'ngx-pagination';
import {
of as observableOf,
of,
} from 'rxjs';
import { RouteService } from '../../../core/services/route.service';
import { MockActivatedRoute } from '../../../shared/mocks/active-router.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { RouterStub } from '../../../shared/testing/router.stub';
import { LdnItemfiltersService } from '../ldn-services-data/ldn-itemfilters-data.service';
import { LdnServicesService } from '../ldn-services-data/ldn-services-data.service';
import { LdnServiceFormComponent } from './ldn-service-form.component';
describe('LdnServiceFormEditComponent', () => {
let component: LdnServiceFormComponent;
let fixture: ComponentFixture<LdnServiceFormComponent>;
let ldnServicesService: LdnServicesService;
let ldnItemfiltersService: any;
let cdRefStub: any;
let modalService: any;
let activatedRoute: MockActivatedRoute;
const testId = '1234';
const routeParams = {
serviceId: testId,
};
const routeUrlSegments = [{ path: 'path' }];
const formMockValue = {
'id': '',
'name': 'name',
'description': 'description',
'url': 'www.test.com',
'ldnUrl': 'https://test.com',
'lowerIp': '127.0.0.1',
'upperIp': '100.100.100.100',
'score': 1,
'inboundPattern': '',
'constraintPattern': '',
'enabled': '',
'type': 'ldnservice',
'notifyServiceInboundPatterns': [
{
'pattern': '',
'patternLabel': 'Select a pattern',
'constraint': '',
'automatic': false,
},
],
};
const translateServiceStub = {
get: () => of('translated-text'),
instant: () => 'translated-text',
onLangChange: new EventEmitter(),
onTranslationChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter(),
};
beforeEach(async () => {
ldnServicesService = jasmine.createSpyObj('ldnServicesService', {
create: observableOf(null),
update: observableOf(null),
findById: createSuccessfulRemoteDataObject$({}),
});
ldnItemfiltersService = {
findAll: () => of(['item1', 'item2']),
};
cdRefStub = Object.assign({
detectChanges: () => fixture.detectChanges(),
});
modalService = {
open: () => {/*comment*/
},
};
activatedRoute = new MockActivatedRoute(routeParams, routeUrlSegments);
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, TranslateModule.forRoot(), NgbDropdownModule],
declarations: [LdnServiceFormComponent],
providers: [
{ provide: LdnServicesService, useValue: ldnServicesService },
{ provide: LdnItemfiltersService, useValue: ldnItemfiltersService },
{ provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: ChangeDetectorRef, useValue: cdRefStub },
{ provide: NgbModal, useValue: modalService },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: TranslateService, useValue: translateServiceStub },
{ provide: PaginationService, useValue: {} },
FormBuilder,
RouteService,
provideMockStore({}),
],
})
.compileComponents();
fixture = TestBed.createComponent(LdnServiceFormComponent);
component = fixture.componentInstance;
spyOn(component, 'filterPatternObjectsAndAssignLabel').and.callFake((a) => a);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
expect(component.formModel instanceof FormGroup).toBeTruthy();
});
it('should init properties correctly', fakeAsync(() => {
spyOn(component, 'fetchServiceData');
spyOn(component, 'setItemfilters');
component.ngOnInit();
tick(100);
expect((component as any).serviceId).toEqual(testId);
expect(component.isNewService).toBeFalsy();
expect(component.areControlsInitialized).toBeTruthy();
expect(component.formModel.controls.notifyServiceInboundPatterns).toBeDefined();
expect(component.fetchServiceData).toHaveBeenCalledWith(testId);
expect(component.setItemfilters).toHaveBeenCalled();
}));
it('should unsubscribe on destroy', () => {
spyOn((component as any).routeSubscription, 'unsubscribe');
component.ngOnDestroy();
expect((component as any).routeSubscription.unsubscribe).toHaveBeenCalled();
});
it('should handle create service with valid form', () => {
spyOn(component, 'fetchServiceData').and.callFake((a) => a);
component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{ pattern: 'patternValue' }]));
const nameInput = fixture.debugElement.query(By.css('#name'));
const descriptionInput = fixture.debugElement.query(By.css('#description'));
const urlInput = fixture.debugElement.query(By.css('#url'));
const scoreInput = fixture.debugElement.query(By.css('#score'));
const lowerIpInput = fixture.debugElement.query(By.css('#lowerIp'));
const upperIpInput = fixture.debugElement.query(By.css('#upperIp'));
const ldnUrlInput = fixture.debugElement.query(By.css('#ldnUrl'));
component.formModel.patchValue(formMockValue);
nameInput.nativeElement.value = 'testName';
descriptionInput.nativeElement.value = 'testDescription';
urlInput.nativeElement.value = 'tetsUrl.com';
ldnUrlInput.nativeElement.value = 'tetsLdnUrl.com';
scoreInput.nativeElement.value = 1;
lowerIpInput.nativeElement.value = '127.0.0.1';
upperIpInput.nativeElement.value = '127.0.0.1';
fixture.detectChanges();
expect(component.formModel.valid).toBeTruthy();
});
it('should handle create service with invalid form', () => {
const nameInput = fixture.debugElement.query(By.css('#name'));
nameInput.nativeElement.value = 'testName';
fixture.detectChanges();
expect(component.formModel.valid).toBeFalsy();
});
it('should not create service with invalid form', () => {
spyOn(component.formModel, 'markAllAsTouched');
spyOn(component, 'closeModal');
component.createService();
expect(component.formModel.markAllAsTouched).toHaveBeenCalled();
expect(component.closeModal).toHaveBeenCalled();
});
it('should create service with valid form', () => {
spyOn(component.formModel, 'markAllAsTouched');
spyOn(component, 'closeModal');
spyOn(component, 'checkPatterns').and.callFake(() => true);
component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{ pattern: 'patternValue' }]));
component.formModel.patchValue(formMockValue);
component.createService();
expect(component.formModel.markAllAsTouched).toHaveBeenCalled();
expect(component.closeModal).not.toHaveBeenCalled();
expect(ldnServicesService.create).toHaveBeenCalled();
});
it('should check patterns', () => {
const arrValid = new FormArray([
new FormGroup({
pattern: new FormControl('pattern'),
}),
]);
const arrInvalid = new FormArray([
new FormGroup({
pattern: new FormControl(''),
}),
]);
expect(component.checkPatterns(arrValid)).toBeTruthy();
expect(component.checkPatterns(arrInvalid)).toBeFalsy();
});
it('should fetch service data', () => {
component.fetchServiceData(testId);
expect(ldnServicesService.findById).toHaveBeenCalledWith(testId);
expect(component.filterPatternObjectsAndAssignLabel).toHaveBeenCalled();
expect((component as any).ldnService).toEqual({});
});
it('should generate patch operations', () => {
spyOn(component as any, 'createReplaceOperation');
spyOn(component as any, 'handlePatterns');
component.generatePatchOperations();
expect((component as any).createReplaceOperation).toHaveBeenCalledTimes(7);
expect((component as any).handlePatterns).toHaveBeenCalled();
});
it('should open modal on submit', () => {
spyOn(component, 'openConfirmModal');
component.onSubmit();
expect(component.openConfirmModal).toHaveBeenCalled();
});
it('should reset form and leave', () => {
spyOn(component as any, 'sendBack');
component.resetFormAndLeave();
expect((component as any).sendBack).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,590 @@
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
TemplateRef,
ViewChild,
} from '@angular/core';
import {
FormArray,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import { Operation } from 'fast-json-patch';
import {
combineLatestWith,
Observable,
Subscription,
} from 'rxjs';
import { RemoteData } from 'src/app/core/data/remote-data';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { IpV4Validator } from '../../../shared/utils/ipV4.validator';
import { LdnItemfiltersService } from '../ldn-services-data/ldn-itemfilters-data.service';
import { LdnServicesService } from '../ldn-services-data/ldn-services-data.service';
import { LDN_SERVICE } from '../ldn-services-model/ldn-service.resource-type';
import { Itemfilter } from '../ldn-services-model/ldn-service-itemfilters';
import { NotifyServicePattern } from '../ldn-services-model/ldn-service-patterns.model';
import { LdnService } from '../ldn-services-model/ldn-services.model';
import { notifyPatterns } from '../ldn-services-patterns/ldn-service-coar-patterns';
/**
* Component for editing LDN service through a form that allows to create or edit the properties of a service
*/
@Component({
selector: 'ds-ldn-service-form',
templateUrl: './ldn-service-form.component.html',
styleUrls: ['./ldn-service-form.component.scss'],
animations: [
trigger('toggleAnimation', [
state('true', style({})),
state('false', style({})),
transition('true <=> false', animate('300ms ease-in')),
]),
],
})
export class LdnServiceFormComponent implements OnInit, OnDestroy {
formModel: FormGroup;
@ViewChild('confirmModal', { static: true }) confirmModal: TemplateRef<any>;
@ViewChild('resetFormModal', { static: true }) resetFormModal: TemplateRef<any>;
public inboundPatterns: string[] = notifyPatterns;
public isNewService: boolean;
public areControlsInitialized: boolean;
public itemfiltersRD$: Observable<RemoteData<PaginatedList<Itemfilter>>>;
public config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 20,
});
public markedForDeletionInboundPattern: number[] = [];
public selectedInboundPatterns: string[];
protected serviceId: string;
private deletedInboundPatterns: number[] = [];
private modalRef: any;
private ldnService: LdnService;
private selectPatternDefaultLabeli18Key = 'ldn-service.form.label.placeholder.default-select';
private routeSubscription: Subscription;
constructor(
protected ldnServicesService: LdnServicesService,
private ldnItemfiltersService: LdnItemfiltersService,
private formBuilder: FormBuilder,
private router: Router,
private route: ActivatedRoute,
private cdRef: ChangeDetectorRef,
protected modalService: NgbModal,
private notificationService: NotificationsService,
private translateService: TranslateService,
protected paginationService: PaginationService,
) {
this.formModel = this.formBuilder.group({
id: [''],
name: ['', Validators.required],
description: [''],
url: ['', Validators.required],
ldnUrl: ['', Validators.required],
lowerIp: ['', [Validators.required, new IpV4Validator()]],
upperIp: ['', [Validators.required, new IpV4Validator()]],
score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''],
constraintPattern: [''],
enabled: [''],
type: LDN_SERVICE.value,
});
}
ngOnInit(): void {
this.routeSubscription = this.route.params.pipe(
combineLatestWith(this.route.url),
).subscribe(([params, segment]) => {
this.serviceId = params.serviceId;
this.isNewService = segment[0].path === 'new';
this.formModel.addControl('notifyServiceInboundPatterns', this.formBuilder.array([this.createInboundPatternFormGroup()]));
this.areControlsInitialized = true;
if (this.serviceId && !this.isNewService) {
this.fetchServiceData(this.serviceId);
}
});
this.setItemfilters();
}
ngOnDestroy(): void {
this.routeSubscription.unsubscribe();
}
/**
* Sets item filters using LDN item filters service
*/
setItemfilters() {
this.itemfiltersRD$ = this.ldnItemfiltersService.findAll().pipe(
getFirstCompletedRemoteData());
}
/**
* Handles the creation of an LDN service by retrieving and validating form fields,
* and submitting the form data to the LDN services endpoint.
*/
createService() {
this.formModel.markAllAsTouched();
const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
const hasInboundPattern = notifyServiceInboundPatterns?.length > 0 ? this.checkPatterns(notifyServiceInboundPatterns) : false;
if (this.formModel.invalid) {
this.closeModal();
return;
}
if (!hasInboundPattern) {
this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title'));
this.closeModal();
return;
}
this.formModel.value.notifyServiceInboundPatterns = this.formModel.value.notifyServiceInboundPatterns.map((pattern: {
pattern: string;
patternLabel: string,
constraintFormatted: string;
}) => {
const { patternLabel, ...rest } = pattern;
delete rest.constraintFormatted;
return rest;
});
const values = { ...this.formModel.value, enabled: true };
const ldnServiceData = this.ldnServicesService.create(values);
ldnServiceData.pipe(
getFirstCompletedRemoteData(),
).subscribe((rd: RemoteData<LdnService>) => {
if (rd.hasSucceeded) {
this.notificationService.success(this.translateService.get('ldn-service-notification.created.success.title'),
this.translateService.get('ldn-service-notification.created.success.body'));
this.closeModal();
this.sendBack();
} else {
if (!this.formModel.errors) {
this.setLdnUrlError();
}
this.notificationService.error(this.translateService.get('ldn-service-notification.created.failure.title'),
this.translateService.get('ldn-service-notification.created.failure.body'));
this.closeModal();
}
});
}
/**
* Checks if at least one pattern in the specified form array has a value.
*
* @param {FormArray} formArray - The form array containing patterns to check.
* @returns {boolean} - True if at least one pattern has a value, otherwise false.
*/
checkPatterns(formArray: FormArray): boolean {
for (let i = 0; i < formArray.length; i++) {
const pattern = formArray.at(i).get('pattern').value;
if (pattern) {
return true;
}
}
return false;
}
/**
* Fetches LDN service data by ID and updates the form
* @param serviceId - The ID of the LDN service
*/
fetchServiceData(serviceId: string): void {
this.ldnServicesService.findById(serviceId).pipe(
getFirstCompletedRemoteData(),
).subscribe(
(data: RemoteData<LdnService>) => {
if (data.hasSucceeded) {
this.ldnService = data.payload;
this.formModel.patchValue({
id: this.ldnService.id,
name: this.ldnService.name,
description: this.ldnService.description,
url: this.ldnService.url,
score: this.ldnService.score,
ldnUrl: this.ldnService.ldnUrl,
type: this.ldnService.type,
enabled: this.ldnService.enabled,
lowerIp: this.ldnService.lowerIp,
upperIp: this.ldnService.upperIp,
});
this.filterPatternObjectsAndAssignLabel('notifyServiceInboundPatterns');
const notifyServiceInboundPatternsFormArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
notifyServiceInboundPatternsFormArray.controls.forEach(
control => {
const controlFormGroup = control as FormGroup;
const controlConstraint = controlFormGroup.get('constraint').value;
controlFormGroup.patchValue({
constraintFormatted: controlConstraint ? this.translateService.instant((controlConstraint as string) + '.label') : '',
});
},
);
}
},
);
}
/**
* Filters pattern objects, initializes form groups, assigns labels, and adds them to the specified form array so the correct string is shown in the dropdown..
* @param formArrayName - The name of the form array to be populated
*/
filterPatternObjectsAndAssignLabel(formArrayName: string) {
const PatternsArray = this.formModel.get(formArrayName) as FormArray;
PatternsArray.clear();
const servicesToUse = this.ldnService.notifyServiceInboundPatterns;
servicesToUse.forEach((patternObj: NotifyServicePattern) => {
const patternFormGroup = this.initializeInboundPatternFormGroup();
const newPatternObjWithLabel = Object.assign(new NotifyServicePattern(), {
...patternObj,
patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternObj?.pattern + '.label'),
});
patternFormGroup.patchValue(newPatternObjWithLabel);
PatternsArray.push(patternFormGroup);
this.cdRef.detectChanges();
});
}
/**
* Generates an array of patch operations based on form changes
* @returns Array of patch operations
*/
generatePatchOperations(): any[] {
const patchOperations: any[] = [];
this.createReplaceOperation(patchOperations, 'name', '/name');
this.createReplaceOperation(patchOperations, 'description', '/description');
this.createReplaceOperation(patchOperations, 'ldnUrl', '/ldnurl');
this.createReplaceOperation(patchOperations, 'url', '/url');
this.createReplaceOperation(patchOperations, 'score', '/score');
this.createReplaceOperation(patchOperations, 'lowerIp', '/lowerIp');
this.createReplaceOperation(patchOperations, 'upperIp', '/upperIp');
this.handlePatterns(patchOperations, 'notifyServiceInboundPatterns');
this.deletedInboundPatterns.forEach(index => {
const removeOperation: Operation = {
op: 'remove',
path: `notifyServiceInboundPatterns[${index}]`,
};
patchOperations.push(removeOperation);
});
return patchOperations;
}
/**
* Submits the form by opening the confirmation modal
*/
onSubmit() {
this.openConfirmModal(this.confirmModal);
}
/**
* Adds a new inbound pattern form group to the array of inbound patterns in the form
*/
addInboundPattern() {
const notifyServiceInboundPatternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
notifyServiceInboundPatternsArray.push(this.createInboundPatternFormGroup());
}
/**
* Selects an inbound pattern by updating its values based on the provided pattern value and index
* @param patternValue - The selected pattern value
* @param index - The index of the inbound pattern in the array
*/
selectInboundPattern(patternValue: string, index: number): void {
const patternArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray);
patternArray.controls[index].patchValue({ pattern: patternValue });
patternArray.controls[index].patchValue({ patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternValue + '.label') });
}
/**
* Selects an inbound item filter by updating its value based on the provided filter value and index
* @param filterValue - The selected filter value
* @param index - The index of the inbound pattern in the array
*/
selectInboundItemFilter(filterValue: string, index: number): void {
const filterArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray);
filterArray.controls[index].patchValue({
constraint: filterValue,
constraintFormatted: this.translateService.instant((filterValue !== '' ? filterValue : 'ldn.no-filter') + '.label'),
});
filterArray.markAllAsTouched();
}
/**
* Toggles the automatic property of an inbound pattern at the specified index
* @param i - The index of the inbound pattern in the array
*/
toggleAutomatic(i: number) {
const automaticControl = this.formModel.get(`notifyServiceInboundPatterns.${i}.automatic`);
if (automaticControl) {
automaticControl.markAsTouched();
automaticControl.setValue(!automaticControl.value);
}
}
/**
* Toggles the enabled status of the LDN service by sending a patch request
*/
toggleEnabled() {
const newStatus = !this.formModel.get('enabled').value;
const patchOperation: Operation = {
op: 'replace',
path: '/enabled',
value: newStatus,
};
this.ldnServicesService.patch(this.ldnService, [patchOperation]).pipe(
getFirstCompletedRemoteData(),
).subscribe(
() => {
this.formModel.get('enabled').setValue(newStatus);
this.cdRef.detectChanges();
},
);
}
/**
* Closes the modal
*/
closeModal() {
this.modalRef.close();
this.cdRef.detectChanges();
}
/**
* Opens a confirmation modal with the specified content
* @param content - The content to be displayed in the modal
*/
openConfirmModal(content) {
this.modalRef = this.modalService.open(content);
}
/**
* Patches the LDN service by retrieving and sending patch operations geenrated in generatePatchOperations()
*/
patchService() {
this.deleteMarkedInboundPatterns();
const patchOperations = this.generatePatchOperations();
this.formModel.markAllAsTouched();
// If the form is invalid, close the modal and return
if (this.formModel.invalid) {
this.closeModal();
return;
}
const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
const deletedInboundPatternsLength = this.deletedInboundPatterns.length;
// If no inbound patterns are specified, close the modal and return
// notify the user that no patterns are specified
if (notifyServiceInboundPatterns.length === deletedInboundPatternsLength) {
this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title'));
this.deletedInboundPatterns = [];
this.closeModal();
return;
}
this.ldnServicesService.patch(this.ldnService, patchOperations).pipe(
getFirstCompletedRemoteData(),
).subscribe(
(rd: RemoteData<LdnService>) => {
if (rd.hasSucceeded) {
this.closeModal();
this.sendBack();
this.notificationService.success(this.translateService.get('admin.registries.services-formats.modify.success.head'),
this.translateService.get('admin.registries.services-formats.modify.success.content'));
} else {
if (!this.formModel.errors) {
this.setLdnUrlError();
}
this.notificationService.error(this.translateService.get('admin.registries.services-formats.modify.failure.head'),
this.translateService.get('admin.registries.services-formats.modify.failure.content'));
this.closeModal();
}
});
}
/**
* Resets the form and navigates back to the LDN services page
*/
resetFormAndLeave() {
this.sendBack();
}
/**
* Marks the specified inbound pattern for deletion
* @param index - The index of the inbound pattern in the array
*/
markForInboundPatternDeletion(index: number) {
if (!this.markedForDeletionInboundPattern.includes(index)) {
this.markedForDeletionInboundPattern.push(index);
}
}
/**
* Unmarks the specified inbound pattern for deletion
* @param index - The index of the inbound pattern in the array
*/
unmarkForInboundPatternDeletion(index: number) {
const i = this.markedForDeletionInboundPattern.indexOf(index);
if (i !== -1) {
this.markedForDeletionInboundPattern.splice(i, 1);
}
}
/**
* Deletes marked inbound patterns from the form model
*/
deleteMarkedInboundPatterns() {
this.markedForDeletionInboundPattern.sort((a, b) => b - a);
const patternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
for (const index of this.markedForDeletionInboundPattern) {
if (index >= 0 && index < patternsArray.length) {
const patternGroup = patternsArray.at(index) as FormGroup;
const patternValue = patternGroup.value;
if (patternValue.isNew) {
patternsArray.removeAt(index);
} else {
this.deletedInboundPatterns.push(index);
}
}
}
this.markedForDeletionInboundPattern = [];
}
/**
* Creates a replace operation and adds it to the patch operations if the form control is dirty
* @param patchOperations - The array to store patch operations
* @param formControlName - The name of the form control
* @param path - The JSON Patch path for the operation
*/
private createReplaceOperation(patchOperations: any[], formControlName: string, path: string): void {
if (this.formModel.get(formControlName).dirty) {
patchOperations.push({
op: 'replace',
path,
value: this.formModel.get(formControlName).value.toString(),
});
}
}
/**
* Handles patterns in the form array, checking if an add or replace operations is required
* @param patchOperations - The array to store patch operations
* @param formArrayName - The name of the form array
*/
private handlePatterns(patchOperations: any[], formArrayName: string): void {
const patternsArray = this.formModel.get(formArrayName) as FormArray;
for (let i = 0; i < patternsArray.length; i++) {
const patternGroup = patternsArray.at(i) as FormGroup;
const patternValue = patternGroup.value;
delete patternValue.constraintFormatted;
if (patternGroup.touched && patternGroup.valid) {
delete patternValue?.patternLabel;
if (patternValue.isNew) {
delete patternValue.isNew;
const addOperation = {
op: 'add',
path: `${formArrayName}/-`,
value: patternValue,
};
patchOperations.push(addOperation);
} else {
const replaceOperation = {
op: 'replace',
path: `${formArrayName}[${i}]`,
value: patternValue,
};
patchOperations.push(replaceOperation);
}
}
}
}
/**
* Navigates back to the LDN services page
*/
private sendBack() {
this.router.navigateByUrl('admin/ldn/services');
}
/**
* Creates a form group for inbound patterns
* @returns The form group for inbound patterns
*/
private createInboundPatternFormGroup(): FormGroup {
const inBoundFormGroup = {
pattern: '',
patternLabel: this.translateService.instant(this.selectPatternDefaultLabeli18Key),
constraint: '',
constraintFormatted: '',
automatic: false,
isNew: true,
};
if (this.isNewService) {
delete inBoundFormGroup.isNew;
}
return this.formBuilder.group(inBoundFormGroup);
}
/**
* Initializes an existing form group for inbound patterns
* @returns The initialized form group for inbound patterns
*/
private initializeInboundPatternFormGroup(): FormGroup {
return this.formBuilder.group({
pattern: '',
patternLabel: '',
constraint: '',
constraintFormatted: '',
automatic: '',
});
}
/**
* set ldnUrl error in case of unprocessable entity and provided value
*/
private setLdnUrlError(): void {
const control = this.formModel.controls.ldnUrl;
const controlErrors = control.errors || {};
control.setErrors({ ...controlErrors, ldnUrlAlreadyAssociated: true });
}
}

View File

@@ -0,0 +1,115 @@
import {
Observable,
of,
} from 'rxjs';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { LDN_SERVICE } from '../ldn-services-model/ldn-service.resource-type';
import { LdnService } from '../ldn-services-model/ldn-services.model';
export const mockLdnService: LdnService = {
uuid: '1',
enabled: false,
score: 0,
id: 1,
lowerIp: '192.0.2.146',
upperIp: '192.0.2.255',
name: 'Service Name',
description: 'Service Description',
url: 'Service URL',
ldnUrl: 'Service LDN URL',
notifyServiceInboundPatterns: [
{
pattern: 'patternA',
constraint: 'itemFilterA',
automatic: 'false',
},
{
pattern: 'patternB',
constraint: 'itemFilterB',
automatic: 'true',
},
],
type: LDN_SERVICE,
_links: {
self: {
href: 'http://localhost/api/ldn/ldnservices/1',
},
},
get self(): string {
return '';
},
};
export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnService);
export const mockLdnServices: LdnService[] = [{
uuid: '1',
enabled: false,
score: 0,
id: 1,
lowerIp: '192.0.2.146',
upperIp: '192.0.2.255',
name: 'Service Name',
description: 'Service Description',
url: 'Service URL',
ldnUrl: 'Service LDN URL',
notifyServiceInboundPatterns: [
{
pattern: 'patternA',
constraint: 'itemFilterA',
automatic: 'false',
},
{
pattern: 'patternB',
constraint: 'itemFilterB',
automatic: 'true',
},
],
type: LDN_SERVICE,
_links: {
self: {
href: 'http://localhost/api/ldn/ldnservices/1',
},
},
get self(): string {
return '';
},
}, {
uuid: '2',
enabled: false,
score: 0,
id: 2,
lowerIp: '192.0.2.146',
upperIp: '192.0.2.255',
name: 'Service Name',
description: 'Service Description',
url: 'Service URL',
ldnUrl: 'Service LDN URL',
notifyServiceInboundPatterns: [
{
pattern: 'patternA',
constraint: 'itemFilterA',
automatic: 'false',
},
{
pattern: 'patternB',
constraint: 'itemFilterB',
automatic: 'true',
},
],
type: LDN_SERVICE,
_links: {
self: {
href: 'http://localhost/api/ldn/ldnservices/1',
},
},
get self(): string {
return '';
},
},
];
export const mockLdnServicesRD$: Observable<RemoteData<PaginatedList<LdnService>>> = of((mockLdnServices as unknown) as RemoteData<PaginatedList<LdnService>>);

View File

@@ -0,0 +1,93 @@
import {
cold,
getTestScheduler,
} from 'jasmine-marbles';
import { of } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RestResponse } from '../../../core/cache/response.models';
import { FindAllData } from '../../../core/data/base/find-all-data';
import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { RequestEntry } from '../../../core/data/request-entry.model';
import { RequestEntryState } from '../../../core/data/request-entry-state.model';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { LdnItemfiltersService } from './ldn-itemfilters-data.service';
describe('LdnItemfiltersService test', () => {
let scheduler: TestScheduler;
let service: LdnItemfiltersService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let responseCacheEntry: RequestEntry;
const endpointURL = `https://rest.api/rest/api/ldn/itemfilters`;
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
const remoteDataMocks = {
Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
};
function initTestService() {
return new LdnItemfiltersService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
);
}
beforeEach(() => {
scheduler = getTestScheduler();
objectCache = {} as ObjectCacheService;
notificationsService = {} as NotificationsService;
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: of(responseCacheEntry),
getByUUID: of(responseCacheEntry),
});
halService = jasmine.createSpyObj('halService', {
getEndpoint: of(endpointURL),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: createSuccessfulRemoteDataObject$({}, 500),
buildList: cold('a', { a: remoteDataMocks.Success }),
});
service = initTestService();
});
describe('composition', () => {
const initFindAllService = () => new LdnItemfiltersService(null, null, null, null, null) as unknown as FindAllData<any>;
testFindAllDataImplementation(initFindAllService);
});
describe('get endpoint', () => {
it('should retrieve correct endpoint', (done) => {
service.getEndpoint().subscribe(() => {
expect(halService.getEndpoint).toHaveBeenCalledWith('itemfilters');
done();
});
});
});
});

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { dataService } from '../../../core/data/base/data-service.decorator';
import {
FindAllData,
FindAllDataImpl,
} from '../../../core/data/base/find-all-data';
import { IdentifiableDataService } from '../../../core/data/base/identifiable-data.service';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { LDN_SERVICE_CONSTRAINT_FILTERS } from '../ldn-services-model/ldn-service.resource-type';
import { Itemfilter } from '../ldn-services-model/ldn-service-itemfilters';
/**
* A service responsible for fetching/sending data from/to the REST API on the itemfilters endpoint
*/
@Injectable()
@dataService(LDN_SERVICE_CONSTRAINT_FILTERS)
export class LdnItemfiltersService extends IdentifiableDataService<Itemfilter> implements FindAllData<Itemfilter> {
private findAllData: FindAllDataImpl<Itemfilter>;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('itemfilters', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Gets the endpoint URL for the itemfilters.
*
* @returns {string} - The endpoint URL.
*/
getEndpoint() {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Finds all itemfilters based on the provided options and link configurations.
*
* @param {FindListOptions} options - The options for finding a list of itemfilters.
* @param {boolean} useCachedVersionIfAvailable - Whether to use the cached version if available.
* @param {boolean} reRequestOnStale - Whether to re-request the data if it's stale.
* @param {...FollowLinkConfig<Itemfilter>[]} linksToFollow - Configurations for following specific links.
* @returns {Observable<RemoteData<PaginatedList<Itemfilter>>>} - An observable of remote data containing a paginated list of itemfilters.
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Itemfilter>[]): Observable<RemoteData<PaginatedList<Itemfilter>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -0,0 +1,134 @@
import {
cold,
getTestScheduler,
} from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { RequestParam } from '../../../core/cache/models/request-param.model';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { RestResponse } from '../../../core/cache/response.models';
import { CreateData } from '../../../core/data/base/create-data';
import { testCreateDataImplementation } from '../../../core/data/base/create-data.spec';
import { DeleteData } from '../../../core/data/base/delete-data';
import { testDeleteDataImplementation } from '../../../core/data/base/delete-data.spec';
import { FindAllData } from '../../../core/data/base/find-all-data';
import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec';
import { PatchData } from '../../../core/data/base/patch-data';
import { testPatchDataImplementation } from '../../../core/data/base/patch-data.spec';
import { SearchData } from '../../../core/data/base/search-data';
import { testSearchDataImplementation } from '../../../core/data/base/search-data.spec';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { RequestEntry } from '../../../core/data/request-entry.model';
import { RequestEntryState } from '../../../core/data/request-entry-state.model';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { mockLdnService } from '../ldn-service-serviceMock/ldnServicesRD$-mock';
import { LdnServicesService } from './ldn-services-data.service';
describe('LdnServicesService test', () => {
let scheduler: TestScheduler;
let service: LdnServicesService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let responseCacheEntry: RequestEntry;
const endpointURL = `https://rest.api/rest/api/ldn/ldnservices`;
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
const remoteDataMocks = {
Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
};
function initTestService() {
return new LdnServicesService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
);
}
beforeEach(() => {
scheduler = getTestScheduler();
objectCache = {} as ObjectCacheService;
notificationsService = {} as NotificationsService;
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry),
});
halService = jasmine.createSpyObj('halService', {
getEndpoint: observableOf(endpointURL),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: createSuccessfulRemoteDataObject$({}, 500),
buildFromRequestUUID: createSuccessfulRemoteDataObject$({}, 500),
buildList: cold('a', { a: remoteDataMocks.Success }),
});
service = initTestService();
});
describe('composition', () => {
const initFindAllService = () => new LdnServicesService(null, null, null, null, null) as unknown as FindAllData<any>;
const initDeleteService = () => new LdnServicesService(null, null, null, null, null) as unknown as DeleteData<any>;
const initSearchService = () => new LdnServicesService(null, null, null, null, null) as unknown as SearchData<any>;
const initPatchService = () => new LdnServicesService(null, null, null, null, null) as unknown as PatchData<any>;
const initCreateService = () => new LdnServicesService(null, null, null, null, null) as unknown as CreateData<any>;
testFindAllDataImplementation(initFindAllService);
testDeleteDataImplementation(initDeleteService);
testSearchDataImplementation(initSearchService);
testPatchDataImplementation(initPatchService);
testCreateDataImplementation(initCreateService);
});
describe('custom methods', () => {
it('should find service by inbound pattern', (done) => {
const params = [new RequestParam('pattern', 'testPattern')];
const findListOptions = Object.assign(new FindListOptions(), {}, { searchParams: params });
spyOn(service, 'searchBy').and.returnValue(observableOf(null));
spyOn((service as any).searchData, 'searchBy').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([mockLdnService])));
service.findByInboundPattern('testPattern').subscribe(() => {
expect(service.searchBy).toHaveBeenCalledWith('byInboundPattern', findListOptions, undefined, undefined );
done();
});
});
it('should invoke service', (done) => {
const constraints = [{ void: true }];
const files = [new File([],'fileName')];
spyOn(service as any, 'getInvocationFormData');
spyOn(service, 'getBrowseEndpoint').and.returnValue(observableOf('testEndpoint'));
service.invoke('serviceName', 'serviceId', constraints, files).subscribe(result => {
expect((service as any).getInvocationFormData).toHaveBeenCalledWith(constraints, files);
expect(service.getBrowseEndpoint).toHaveBeenCalled();
expect(result).toBeInstanceOf(RemoteData);
done();
});
});
});
});

View File

@@ -0,0 +1,230 @@
import { Injectable } from '@angular/core';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import {
map,
take,
} from 'rxjs/operators';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { RequestParam } from '../../../core/cache/models/request-param.model';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import {
CreateData,
CreateDataImpl,
} from '../../../core/data/base/create-data';
import { dataService } from '../../../core/data/base/data-service.decorator';
import {
DeleteData,
DeleteDataImpl,
} from '../../../core/data/base/delete-data';
import {
FindAllData,
FindAllDataImpl,
} from '../../../core/data/base/find-all-data';
import { IdentifiableDataService } from '../../../core/data/base/identifiable-data.service';
import {
PatchData,
PatchDataImpl,
} from '../../../core/data/base/patch-data';
import { SearchDataImpl } from '../../../core/data/base/search-data';
import { ChangeAnalyzer } from '../../../core/data/change-analyzer';
import { FindListOptions } from '../../../core/data/find-list-options.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { MultipartPostRequest } from '../../../core/data/request.models';
import { RequestService } from '../../../core/data/request.service';
import { RestRequest } from '../../../core/data/rest-request.model';
import { RestRequestMethod } from '../../../core/data/rest-request-method';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { LdnServiceConstrain } from '../ldn-services-model/ldn-service.constrain.model';
import { LDN_SERVICE } from '../ldn-services-model/ldn-service.resource-type';
import { LdnService } from '../ldn-services-model/ldn-services.model';
/**
* Injectable service responsible for fetching/sending data from/to the REST API on the ldnservices endpoint.
*
* @export
* @class LdnServicesService
* @extends {IdentifiableDataService<LdnService>}
* @implements {FindAllData<LdnService>}
* @implements {DeleteData<LdnService>}
* @implements {PatchData<LdnService>}
* @implements {CreateData<LdnService>}
*/
@Injectable()
@dataService(LDN_SERVICE)
export class LdnServicesService extends IdentifiableDataService<LdnService> implements FindAllData<LdnService>, DeleteData<LdnService>, PatchData<LdnService>, CreateData<LdnService> {
createData: CreateDataImpl<LdnService>;
private findAllData: FindAllDataImpl<LdnService>;
private deleteData: DeleteDataImpl<LdnService>;
private patchData: PatchDataImpl<LdnService>;
private comparator: ChangeAnalyzer<LdnService>;
private searchData: SearchDataImpl<LdnService>;
private findByPatternEndpoint = 'byInboundPattern';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('ldnservices', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.comparator, this.responseMsToLive, this.constructIdEndpoint);
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
}
/**
* Creates an LDN service by sending a POST request to the REST API.
*
* @param {LdnService} object - The LDN service object to be created.
* @param params Array with additional params to combine with query string
* @returns {Observable<RemoteData<LdnService>>} - Observable containing the result of the creation operation.
*/
create(object: LdnService, ...params: RequestParam[]): Observable<RemoteData<LdnService>> {
return this.createData.create(object, ...params);
}
/**
* Updates an LDN service by applying a set of operations through a PATCH request to the REST API.
*
* @param {LdnService} object - The LDN service object to be updated.
* @param {Operation[]} operations - The patch operations to be applied.
* @returns {Observable<RemoteData<LdnService>>} - Observable containing the result of the update operation.
*/
patch(object: LdnService, operations: Operation[]): Observable<RemoteData<LdnService>> {
return this.patchData.patch(object, operations);
}
/**
* Updates an LDN service by sending a PUT request to the REST API.
*
* @param {LdnService} object - The LDN service object to be updated.
* @returns {Observable<RemoteData<LdnService>>} - Observable containing the result of the update operation.
*/
update(object: LdnService): Observable<RemoteData<LdnService>> {
return this.patchData.update(object);
}
/**
* Commits pending updates by sending a PATCH request to the REST API.
*
* @param {RestRequestMethod} [method] - The HTTP method to be used for the request.
*/
commitUpdates(method?: RestRequestMethod): void {
return this.patchData.commitUpdates(method);
}
/**
* Creates a patch representing the changes made to the LDN service in the cache.
*
* @param {LdnService} object - The LDN service object for which to create the patch.
* @returns {Observable<Operation[]>} - Observable containing the patch operations.
*/
createPatchFromCache(object: LdnService): Observable<Operation[]> {
return this.patchData.createPatchFromCache(object);
}
/**
* Retrieves all LDN services from the REST API based on the provided options.
*
* @param {FindListOptions} [options] - The options to be applied to the request.
* @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available.
* @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale.
* @param {...FollowLinkConfig<LdnService>[]} linksToFollow - Optional links to follow during the request.
* @returns {Observable<RemoteData<PaginatedList<LdnService>>>} - Observable containing the result of the request.
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<LdnService>[]): Observable<RemoteData<PaginatedList<LdnService>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Retrieves LDN services based on the inbound pattern from the REST API.
*
* @param {string} pattern - The inbound pattern to be used in the search.
* @param {FindListOptions} [options] - The options to be applied to the request.
* @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available.
* @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale.
* @param {...FollowLinkConfig<LdnService>[]} linksToFollow - Optional links to follow during the request.
* @returns {Observable<RemoteData<PaginatedList<LdnService>>>} - Observable containing the result of the request.
*/
findByInboundPattern(pattern: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<LdnService>[]): Observable<RemoteData<PaginatedList<LdnService>>> {
const params = [new RequestParam('pattern', pattern)];
const findListOptions = Object.assign(new FindListOptions(), options, { searchParams: params });
return this.searchBy(this.findByPatternEndpoint, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Deletes an LDN service by sending a DELETE request to the REST API.
*
* @param {string} objectId - The ID of the LDN service to be deleted.
* @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion.
* @returns {Observable<RemoteData<NoContent>>} - Observable containing the result of the deletion operation.
*/
public delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.delete(objectId, copyVirtualMetadata);
}
/**
* Deletes an LDN service by its HATEOAS link.
*
* @param {string} href - The HATEOAS link of the LDN service to be deleted.
* @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion.
* @returns {Observable<RemoteData<NoContent>>} - Observable containing the result of the deletion operation.
*/
public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<LdnService>[]): Observable<RemoteData<PaginatedList<LdnService>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
public invoke(serviceName: string, serviceId: string, parameters: LdnServiceConstrain[], files: File[]): Observable<RemoteData<LdnService>> {
const requestId = this.requestService.generateRequestId();
this.getBrowseEndpoint().pipe(
take(1),
map((endpoint: string) => new URLCombiner(endpoint, serviceName, 'processes', serviceId).toString()),
map((endpoint: string) => {
const body = this.getInvocationFormData(parameters, files);
return new MultipartPostRequest(requestId, endpoint, body);
}),
).subscribe((request: RestRequest) => this.requestService.send(request));
return this.rdbService.buildFromRequestUUID<LdnService>(requestId);
}
private getInvocationFormData(constrain: LdnServiceConstrain[], files: File[]): FormData {
const form: FormData = new FormData();
form.set('properties', JSON.stringify(constrain));
files.forEach((file: File) => {
form.append('file', file);
});
return form;
}
}

View File

@@ -0,0 +1,99 @@
<div class="container">
<div class="d-flex">
<h1 class="flex-grow-1">{{ 'ldn-registered-services.title' | translate }}</h1>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-success" routerLink="/admin/ldn/services/new"><i
class="fas fa-plus pr-2"></i>{{ 'process.overview.new' | translate }}</button>
</div>
<ds-pagination *ngIf="(ldnServicesRD$ | async)?.payload?.totalElements > 0"
[collectionSize]="(ldnServicesRD$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[pageInfoState]="(ldnServicesRD$ | async)?.payload"
[paginationOptions]="pageConfig">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{ 'service.overview.table.name' | translate }}</th>
<th scope="col">{{ 'service.overview.table.description' | translate }}</th>
<th scope="col">{{ 'service.overview.table.status' | translate }}</th>
<th scope="col">{{ 'service.overview.table.actions' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ldnService of (ldnServicesRD$ | async)?.payload?.page">
<td class="col-3">{{ ldnService.name }}</td>
<td>
<ds-truncatable [id]="ldnService.id">
<ds-truncatable-part [id]="ldnService.id" [minLines]="2">
<div>
{{ ldnService.description }}
</div>
</ds-truncatable-part>
</ds-truncatable>
</td>
<td>
<span (click)="toggleStatus(ldnService, ldnServicesService)"
[ngClass]="{ 'status-enabled': ldnService.enabled, 'status-disabled': !ldnService.enabled }"
[title]="ldnService.enabled ? ('ldn-service.overview.table.clickToDisable' | translate) : ('ldn-service.overview.table.clickToEnable' | translate)"
class="status-indicator">
{{ ldnService.enabled ? ('ldn-service.overview.table.enabled' | translate) : ('ldn-service.overview.table.disabled' | translate) }}
</span>
</td>
<td>
<div class="btn-group">
<button
(click)="selectServiceToDelete(ldnService.id)"
[attr.aria-label]="'ldn-service-overview-select-delete' | translate"
class="btn btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
<button [routerLink]="['/admin/ldn/services/edit/', ldnService.id]"
[attr.aria-label]="'ldn-service-overview-select-edit' | translate"
class="btn btn-outline-dark">
<i class="fas fa-edit"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
</div>
<ng-template #deleteModal>
<div>
<div class="modal-header">
<div>
<h4>{{'service.overview.delete.header' | translate }}</h4>
</div>
<button (click)="closeModal()" aria-label="Close"
[attr.aria-label]="'ldn-service-overview-close-modal' | translate"
class="close" type="button">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div>
{{ 'service.overview.delete.body' | translate }}
</div>
<div class="mt-4">
<button (click)="closeModal()"
[attr.aria-label]="'ldn-service-overview-close-modal' | translate"
class="btn btn-primary mr-2">{{ 'service.detail.delete.cancel' | translate }}</button>
<button (click)="deleteSelected(this.selectedServiceId.toString(), ldnServicesService)"
class="btn btn-danger"
[attr.aria-label]="'ldn-service-overview-select-delete' | translate"
id="delete-confirm">{{ 'service.overview.delete' | translate }}
</button>
</div>
</div>
</div>
</ng-template>

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