Merge branch 'main' into CST-12109-WITHDRAWN-REINSTATE-requests

This commit is contained in:
Alisa Ismailati
2024-01-17 16:02:21 +01:00
502 changed files with 15981 additions and 5169 deletions

View File

@@ -43,11 +43,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 +118,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 +191,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 +203,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: with:
# https://github.com/actions/checkout build_id: dspace-angular
- name: Checkout codebase image_name: dspace/dspace-angular
uses: actions/checkout@v3 dockerfile_path: ./Dockerfile
secrets:
# https://github.com/docker/setup-buildx-action DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Setup Docker Buildx DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
uses: docker/setup-buildx-action@v2
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU emulation to build for multiple architectures
uses: docker/setup-qemu-action@v2
# https://github.com/docker/login-action
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
# https://github.com/docker/metadata-action
# Get Metadata for docker_build step below
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image
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: with:
# https://github.com/actions/checkout build_id: dspace-angular-dist
- name: Checkout codebase image_name: dspace/dspace-angular
uses: actions/checkout@v3 dockerfile_path: ./Dockerfile.dist
# As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
# https://github.com/docker/setup-buildx-action # tagging logic as the primary 'dspace/dspace-angular' image above.
- name: Setup Docker Buildx tags_flavor: suffix=-dist
uses: docker/setup-buildx-action@v2 secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
# https://github.com/docker/setup-qemu-action DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Set up QEMU emulation to build for multiple architectures # Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of
uses: docker/setup-qemu-action@v2 # these sites as specified in reusable-docker-build.xml
REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }}
# https://github.com/docker/login-action REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }}
- name: Login to DockerHub
# Only login if not a PR, as PRs only trigger a Docker build and not a push
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
# https://github.com/docker/metadata-action
# Get Metadata for docker_build_dist step below
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image
id: meta_build_dist
uses: docker/metadata-action@v4
with:
images: dspace/dspace-angular
tags: ${{ env.IMAGE_TAGS }}
# As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
# tagging logic as the primary 'dspace/dspace-angular' image above.
flavor: ${{ env.TAGS_FLAVOR }}
suffix=-dist
- name: Build and push 'dspace-angular-dist' image
id: docker_build_dist
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.dist
platforms: ${{ env.PLATFORMS }}
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
# but we ONLY do an image push to DockerHub if it's NOT a PR
push: ${{ github.event_name != 'pull_request' }}
# Use tags / labels provided by 'docker/metadata-action' above
tags: ${{ steps.meta_build_dist.outputs.tags }}
labels: ${{ steps.meta_build_dist.outputs.labels }}

View File

@@ -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)
@@ -39,6 +39,8 @@ jobs:
# Copy all labels from original PR to (newly created) port PR # Copy all labels from original PR to (newly created) port PR
# NOTE: The labels matching 'label_pattern' are automatically excluded # NOTE: The labels matching 'label_pattern' are automatically excluded
copy_labels_pattern: '.*' copy_labels_pattern: '.*'
# Skip any merge commits in the ported PR. This means only non-merge commits are cherry-picked to the new PR
merge_commits: 'skip'
# Use a personal access token (PAT) to create PR as 'dspace-bot' user. # Use a personal access token (PAT) to create PR as 'dspace-bot' user.
# A PAT is required in order for the new PR to trigger its own actions (for CI checks) # A PAT is required in order for the new PR to trigger its own actions (for CI checks)
github_token: ${{ secrets.PR_PORT_TOKEN }} github_token: ${{ secrets.PR_PORT_TOKEN }}

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

@@ -157,8 +157,8 @@ DSPACE_UI_SSL => DSPACE_SSL
The same settings can also be overwritten by setting system environment variables instead, E.g.: The same settings can also be overwritten by setting system environment variables instead, E.g.:
```bash ```bash
export DSPACE_HOST=api7.dspace.org export DSPACE_HOST=demo.dspace.org
export DSPACE_UI_PORT=4200 export DSPACE_UI_PORT=4000
``` ```
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`** The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`**
@@ -288,7 +288,7 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con
The test files can be found in the `./cypress/integration/` folder. The test files can be found in the `./cypress/integration/` folder.
Before you can run e2e tests, two things are REQUIRED: Before you can run e2e tests, two things are REQUIRED:
1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time. 1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo/sandbox REST API (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/), as those sites may have content added/removed at any time.
* After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend. * After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend.
* If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example: * If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example:
``` ```

View File

@@ -22,7 +22,7 @@ ui:
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. # 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
rest: rest:
ssl: true ssl: true
host: api7.dspace.org host: sandbox.dspace.org
port: 443 port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server nameSpace: /server
@@ -208,6 +208,9 @@ languages:
- code: pt-BR - code: pt-BR
label: Português do Brasil label: Português do Brasil
active: true active: true
- code: sr-lat
label: Srpski (lat)
active: true
- code: fi - code: fi
label: Suomi label: Suomi
active: true active: true
@@ -232,6 +235,9 @@ languages:
- code: el - code: el
label: Ελληνικά label: Ελληνικά
active: true active: true
- code: sr-cyr
label: Српски
active: true
- code: uk - code: uk
label: раї́нська label: раї́нська
active: true active: true
@@ -292,33 +298,33 @@ themes:
# #
# # A theme with a handle property will match the community, collection or item with the given # # A theme with a handle property will match the community, collection or item with the given
# # handle, and all collections and/or items within it # # handle, and all collections and/or items within it
# - name: 'custom', # - name: custom
# handle: '10673/1233' # handle: 10673/1233
# #
# # A theme with a regex property will match the route using a regular expression. If it # # A theme with a regex property will match the route using a regular expression. If it
# # matches the route for a community or collection it will also apply to all collections # # matches the route for a community or collection it will also apply to all collections
# # and/or items within it # # and/or items within it
# - name: 'custom', # - name: custom
# regex: 'collections\/e8043bc2.*' # regex: collections\/e8043bc2.*
# #
# # A theme with a uuid property will match the community, collection or item with the given # # A theme with a uuid property will match the community, collection or item with the given
# # ID, and all collections and/or items within it # # ID, and all collections and/or items within it
# - name: 'custom', # - name: custom
# uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' # uuid: 0958c910-2037-42a9-81c7-dca80e3892b4
# #
# # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found # # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
# # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default. # # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
# - name: 'custom-A', # - name: custom-A
# extends: 'custom-B', # extends: custom-B
# # Any of the matching properties above can be used # # Any of the matching properties above can be used
# handle: '10673/34' # handle: 10673/34
# #
# - name: 'custom-B', # - name: custom-B
# extends: 'custom', # extends: custom
# handle: '10673/12' # handle: 10673/12
# #
# # A theme with only a name will match every route # # A theme with only a name will match every route
# name: 'custom' # name: custom
# #
# # This theme will use the default bootstrap styling for DSpace components # # This theme will use the default bootstrap styling for DSpace components
# - name: BASE_THEME_NAME # - name: BASE_THEME_NAME
@@ -379,4 +385,4 @@ vocabularies:
# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. # Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
comcolSelectionSort: comcolSelectionSort:
sortField: 'dc.title' sortField: 'dc.title'
sortDirection: 'ASC' sortDirection: 'ASC'

View File

@@ -1,5 +1,5 @@
rest: rest:
ssl: false ssl: true
host: localhost host: sandbox.dspace.org
port: 8080 port: 443
nameSpace: /server nameSpace: /server

View File

@@ -1,4 +1,3 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Community List Page', () => { describe('Community List Page', () => {
@@ -13,13 +12,6 @@ describe('Community List Page', () => {
cy.get('[data-test="expand-button"]').click({ multiple: true }); cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze <ds-community-list-page> for accessibility issues // Analyze <ds-community-list-page> for accessibility issues
// Disable heading-order checks until it is fixed testA11y('ds-community-list-page');
testA11y('ds-community-list-page',
{
rules: {
'heading-order': { enabled: false }
}
} as Options
);
}); });
}); });

View File

@@ -8,12 +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
['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149
],
});
}); });
}); });

View File

@@ -1,4 +1,3 @@
import { Options } from 'cypress-axe';
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
@@ -19,13 +18,16 @@ describe('Item Page', () => {
cy.get('ds-item-page').should('be.visible'); cy.get('ds-item-page').should('be.visible');
// Analyze <ds-item-page> for accessibility issues // Analyze <ds-item-page> for accessibility issues
// Disable heading-order checks until it is fixed testA11y('ds-item-page');
testA11y('ds-item-page', });
{
rules: { it('should pass accessibility tests on full item page', () => {
'heading-order': { enabled: false } cy.visit(ENTITYPAGE + '/full');
}
} as Options // <ds-full-item-page> tag must be loaded
); cy.get('ds-full-item-page').should('be.visible');
// Analyze <ds-full-item-page> for accessibility issues
testA11y('ds-full-item-page');
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
const page = { const page = {
openLoginMenu() { openLoginMenu() {
@@ -123,4 +124,15 @@ describe('Login Modal', () => {
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');
}); });
it('should pass accessibility tests', () => {
cy.visit('/');
page.openLoginMenu();
cy.get('ds-log-in').should('exist');
// Analyze <ds-log-in> for accessibility issues
testA11y('ds-log-in');
});
}); });

View File

@@ -19,21 +19,7 @@ describe('My DSpace page', () => {
cy.get('.filter-toggle').click({ multiple: true }); cy.get('.filter-toggle').click({ multiple: true });
// Analyze <ds-my-dspace-page> for accessibility issues // Analyze <ds-my-dspace-page> for accessibility issues
testA11y( testA11y('ds-my-dspace-page');
{
include: ['ds-my-dspace-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
}); });
it('should have a working detailed view that passes accessibility tests', () => { it('should have a working detailed view that passes accessibility tests', () => {

View File

@@ -1,8 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('PageNotFound', () => { describe('PageNotFound', () => {
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
// request an invalid page (UUIDs at root path aren't valid) // request an invalid page (UUIDs at root path aren't valid)
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
cy.get('ds-pagenotfound').should('be.visible'); cy.get('ds-pagenotfound').should('be.visible');
// Analyze <ds-pagenotfound> for accessibility issues
testA11y('ds-pagenotfound');
}); });
it('should not contain element ds-pagenotfound when navigating to existing page', () => { it('should not contain element ds-pagenotfound when navigating to existing page', () => {

View File

@@ -27,21 +27,7 @@ describe('Search Page', () => {
cy.get('[data-test="filter-toggle"]').click({ multiple: true }); cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-search-page> for accessibility issues // Analyze <ds-search-page> for accessibility issues
testA11y( testA11y('ds-search-page');
{
include: ['ds-search-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
}); });
it('should have a working grid view that passes accessibility tests', () => { it('should have a working grid view that passes accessibility tests', () => {

View File

@@ -177,6 +177,8 @@ function generateViewEvent(uuid: string, dsoType: string): void {
[XSRF_REQUEST_HEADER] : csrfToken, [XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot" // use a known public IP address to avoid being seen as a "bot"
'X-Forwarded-For': '1.1.1.1', 'X-Forwarded-For': '1.1.1.1',
// Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot"
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
}, },
//form: true, // indicates the body should be form urlencoded //form: true, // indicates the body should be form urlencoded
body: { targetId: uuid, targetType: dsoType }, body: { targetId: uuid, targetType: dsoType },

View File

@@ -101,8 +101,8 @@ and the backend at http://localhost:8080/server/
## Run DSpace Angular dist build with DSpace Demo site backend ## Run DSpace Angular dist build with DSpace Demo site backend
This allows you to run the Angular UI in *production* mode, pointing it at the demo backend This allows you to run the Angular UI in *production* mode, pointing it at the demo or sandbox backend
(https://api7.dspace.org/server/). (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/).
``` ```
docker-compose -f docker/docker-compose-dist.yml pull docker-compose -f docker/docker-compose-dist.yml pull

View File

@@ -24,7 +24,7 @@ services:
# This is because Server Side Rendering (SSR) currently requires a public URL, # This is because Server Side Rendering (SSR) currently requires a public URL,
# see this bug: https://github.com/DSpace/dspace-angular/issues/1485 # see this bug: https://github.com/DSpace/dspace-angular/issues/1485
DSPACE_REST_SSL: 'true' DSPACE_REST_SSL: 'true'
DSPACE_REST_HOST: api7.dspace.org DSPACE_REST_HOST: sandbox.dspace.org
DSPACE_REST_PORT: 443 DSPACE_REST_PORT: 443
DSPACE_REST_NAMESPACE: /server DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist

View File

@@ -48,7 +48,7 @@ dspace-angular connects to your DSpace installation by using its REST endpoint.
```yaml ```yaml
rest: rest:
ssl: true ssl: true
host: api7.dspace.org host: demo.dspace.org
port: 443 port: 443
nameSpace: /server nameSpace: /server
} }
@@ -57,7 +57,7 @@ rest:
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
``` ```
DSPACE_REST_SSL=true DSPACE_REST_SSL=true
DSPACE_REST_HOST=api7.dspace.org DSPACE_REST_HOST=demo.dspace.org
DSPACE_REST_PORT=443 DSPACE_REST_PORT=443
DSPACE_REST_NAMESPACE=/server DSPACE_REST_NAMESPACE=/server
``` ```

View File

@@ -15,14 +15,14 @@
"analyze": "webpack-bundle-analyzer dist/browser/stats.json", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build --configuration development", "build": "ng build --configuration development",
"build:stats": "ng build --stats-json", "build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr", "build:prod": "cross-env NODE_ENV=production yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"test": "ng test --source-map=true --watch=false --configuration test", "test": "ng test --source-map=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint", "lint": "ng lint",
"lint-fix": "ng lint --fix=true", "lint-fix": "ng lint --fix=true",
"e2e": "ng e2e", "e2e": "cross-env NODE_ENV=production ng e2e",
"clean:dev:config": "rimraf src/assets/config.json", "clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage", "clean:coverage": "rimraf coverage",
"clean:dist": "rimraf dist", "clean:dist": "rimraf dist",
@@ -82,7 +82,7 @@
"@types/grecaptcha": "^3.0.4", "@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0", "angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^0.27.2", "axios": "^1.6.0",
"bootstrap": "^4.6.1", "bootstrap": "^4.6.1",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -99,6 +99,7 @@
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^1.0.5",
"http-terminator": "^3.2.0",
"isbot": "^3.6.10", "isbot": "^3.6.10",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@@ -116,12 +117,12 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ng-mocks": "^14.10.0", "ng-mocks": "^14.10.0",
"ng2-file-upload": "1.4.0", "ng2-file-upload": "1.4.0",
"ng2-nouislider": "^1.8.3", "ng2-nouislider": "^2.0.0",
"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": "^14.6.3", "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",
@@ -159,11 +160,11 @@
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1", "@typescript-eslint/parser": "^5.59.1",
"axe-core": "^4.7.0", "axe-core": "^4.7.2",
"compression-webpack-plugin": "^9.2.0", "compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "12.10.0", "cypress": "12.17.4",
"cypress-axe": "^1.4.0", "cypress-axe": "^1.4.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"eslint": "^8.39.0", "eslint": "^8.39.0",

View File

@@ -32,6 +32,7 @@ import isbot from 'isbot';
import { createCertificate } from 'pem'; import { createCertificate } from 'pem';
import { createServer } from 'https'; import { createServer } from 'https';
import { json } from 'body-parser'; import { json } from 'body-parser';
import { createHttpTerminator } from 'http-terminator';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -320,22 +321,23 @@ function initCache() {
if (botCacheEnabled()) { if (botCacheEnabled()) {
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first) // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
// See https://www.npmjs.com/package/lru-cache // See https://www.npmjs.com/package/lru-cache
// When enabled, each page defaults to expiring after 1 day // When enabled, each page defaults to expiring after 1 day (defined in default-app-config.ts)
botCache = new LRU( { botCache = new LRU( {
max: environment.cache.serverSide.botCache.max, max: environment.cache.serverSide.botCache.max,
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day ttl: environment.cache.serverSide.botCache.timeToLive,
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting allowStale: environment.cache.serverSide.botCache.allowStale
}); });
} }
if (anonymousCacheEnabled()) { if (anonymousCacheEnabled()) {
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive // NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
// may expire pages more frequently. // may expire pages more frequently.
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content) // When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts)
// to minimize anonymous users seeing out-of-date content
anonymousCache = new LRU( { anonymousCache = new LRU( {
max: environment.cache.serverSide.anonymousCache.max, max: environment.cache.serverSide.anonymousCache.max,
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds ttl: environment.cache.serverSide.anonymousCache.timeToLive,
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting allowStale: environment.cache.serverSide.anonymousCache.allowStale
}); });
} }
} }
@@ -487,7 +489,7 @@ function saveToCache(req, page: any) {
*/ */
function hasNotSucceeded(statusCode) { function hasNotSucceeded(statusCode) {
const rgx = new RegExp(/^20+/); const rgx = new RegExp(/^20+/);
return !rgx.test(statusCode) return !rgx.test(statusCode);
} }
function retrieveHeaders(response) { function retrieveHeaders(response) {
@@ -525,23 +527,46 @@ function serverStarted() {
* @param keys SSL credentials * @param keys SSL credentials
*/ */
function createHttpsServer(keys) { function createHttpsServer(keys) {
createServer({ const listener = createServer({
key: keys.serviceKey, key: keys.serviceKey,
cert: keys.certificate cert: keys.certificate
}, app).listen(environment.ui.port, environment.ui.host, () => { }, app).listen(environment.ui.port, environment.ui.host, () => {
serverStarted(); serverStarted();
}); });
// Graceful shutdown when signalled
const terminator = createHttpTerminator({server: listener});
process.on('SIGINT', () => {
void (async ()=> {
console.debug('Closing HTTPS server on signal');
await terminator.terminate().catch(e => { console.error(e); });
console.debug('HTTPS server closed');
})();
});
} }
/**
* Create an HTTP server with the configured port and host.
*/
function run() { function run() {
const port = environment.ui.port || 4000; const port = environment.ui.port || 4000;
const host = environment.ui.host || '/'; const host = environment.ui.host || '/';
// Start up the Node server // Start up the Node server
const server = app(); const server = app();
server.listen(port, host, () => { const listener = server.listen(port, host, () => {
serverStarted(); serverStarted();
}); });
// Graceful shutdown when signalled
const terminator = createHttpTerminator({server: listener});
process.on('SIGINT', () => {
void (async () => {
console.debug('Closing HTTP server on signal');
await terminator.terminate().catch(e => { console.error(e); });
console.debug('HTTP server closed.');return undefined;
})();
});
} }
function start() { function start() {

View File

@@ -1,12 +1,22 @@
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAccessControlModuleRoute } from '../app-routing-paths'; import { getAccessControlModuleRoute } from '../app-routing-paths';
export const GROUP_EDIT_PATH = 'groups'; export const EPERSON_PATH = 'epeople';
export function getEPersonsRoute(): string {
return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString();
}
export function getEPersonEditRoute(id: string): string {
return new URLCombiner(getEPersonsRoute(), id, 'edit').toString();
}
export const GROUP_PATH = 'groups';
export function getGroupsRoute() { export function getGroupsRoute() {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString();
} }
export function getGroupEditRoute(id: string) { export function getGroupEditRoute(id: string) {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); return new URLCombiner(getGroupsRoute(), id, 'edit').toString();
} }

View File

@@ -3,7 +3,7 @@ import { RouterModule } from '@angular/router';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { GROUP_EDIT_PATH } from './access-control-routing-paths'; 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 { GroupPageGuard } from './group-registry/group-page.guard';
import { import {
@@ -13,12 +13,14 @@ import {
SiteAdministratorGuard SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; } 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 { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ {
path: 'epeople', path: EPERSON_PATH,
component: EPeopleRegistryComponent, component: EPeopleRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -27,7 +29,26 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [SiteAdministratorGuard] canActivate: [SiteAdministratorGuard]
}, },
{ {
path: GROUP_EDIT_PATH, path: `${EPERSON_PATH}/create`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
canActivate: [SiteAdministratorGuard],
},
{
path: `${EPERSON_PATH}/:id/edit`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
ePerson: EPersonResolver,
},
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
canActivate: [SiteAdministratorGuard],
},
{
path: GROUP_PATH,
component: GroupsRegistryComponent, component: GroupsRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -36,7 +57,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/newGroup`, path: `${GROUP_PATH}/create`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -45,7 +66,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/:groupId`, path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver

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,51 +17,53 @@
</div> </div>
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills"> <div id="bulk-access-browse-panel-content">
<li [ngbNavItem]="'search'"> <ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a> <li [ngbNavItem]="'search'" role="presentation">
<ng-template ngbNavContent> <a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<div class="mx-n3"> <ng-template ngbNavContent>
<ds-themed-search [configuration]="'administrativeBulkAccess'" <div class="mx-n3">
[selectable]="true" <ds-themed-search [configuration]="'administrativeBulkAccess'"
[selectionConfig]="{ repeatable: true, listId: listId }" [selectable]="true"
[showThumbnails]="false"></ds-themed-search> [selectionConfig]="{ repeatable: true, listId: listId }"
</div> [showThumbnails]="false"></ds-themed-search>
</ng-template> </div>
</li> </ng-template>
<li [ngbNavItem]="'selected'"> </li>
<a ngbNavLink> <li [ngbNavItem]="'selected'" role="presentation">
{{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }} <a ngbNavLink>
</a> {{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }}
<ng-template ngbNavContent> </a>
<ds-pagination <ng-template ngbNavContent>
[paginationOptions]="(paginationOptions$ | async)" <ds-pagination
[pageInfoState]="(objectsSelected$|async)?.payload.pageInfo" [paginationOptions]="(paginationOptions$ | async)"
[collectionSize]="(objectsSelected$|async)?.payload?.totalElements" [pageInfoState]="(objectsSelected$|async)?.payload.pageInfo"
[objects]="(objectsSelected$|async)" [collectionSize]="(objectsSelected$|async)?.payload?.totalElements"
[showPaginator]="false" [objects]="(objectsSelected$|async)"
(prev)="pagePrev()" [showPaginator]="false"
(next)="pageNext()"> (prev)="pagePrev()"
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4"> (next)="pageNext()">
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize, <ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4">
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last ' <li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
class="mt-4 mb-4 d-flex" currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last '
[attr.data-test]="'list-object' | dsBrowserOnly"> class="mt-4 mb-4 d-flex"
<ds-selectable-list-item-control [index]="i" [attr.data-test]="'list-object' | dsBrowserOnly">
[object]="object" <ds-selectable-list-item-control [index]="i"
[selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control> [object]="object"
<ds-listable-object-component-loader [listID]="listId" [selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control>
[index]="i" <ds-listable-object-component-loader [listID]="listId"
[object]="object" [index]="i"
[showThumbnails]="false" [object]="object"
[viewMode]="'list'"></ds-listable-object-component-loader> [showThumbnails]="false"
</li> [viewMode]="'list'"></ds-listable-object-component-loader>
</ul> </li>
</ds-pagination> </ul>
</ng-template> </ds-pagination>
</li> </ng-template>
</ul> </li>
<div [ngbNavOutlet]="nav" class="mt-5"></div> </ul>
<div [ngbNavOutlet]="nav" class="mt-5"></div>
</div>
</ng-template> </ng-template>
</ngb-panel> </ngb-panel>
</ngb-accordion> </ngb-accordion>

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

@@ -2,98 +2,93 @@
<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 *ngIf="!isEPersonFormShown"> <div>
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
(click)="isEPersonFormShown = true"> [routerLink]="'create'">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()" <h2 id="search" class="border-bottom pb-2">
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form> {{labelPrefix + 'search.head' | translate}}
</h2>
<div *ngIf="!isEPersonFormShown"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}} <div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
</h3> <option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
<div> </select>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> </div>
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option> <div class="flex-grow-1 mr-3 ml-3">
<option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option> <div class="form-group input-group">
</select> <input type="text" name="query" id="query" formControlName="query"
</div> class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
<div class="flex-grow-1 mr-3 ml-3"> [placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<div class="form-group input-group"> <span class="input-group-append">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }} <i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
</button> </button>
</span> </span>
</div>
</div> </div>
<div>
<button (click)="clearFormAndResetResult();"
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
</div>
</form>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epeople" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
<th>{{labelPrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>
<td>
<div class="btn-group edit-field">
<button (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div> </div>
<div>
<button (click)="clearFormAndResetResult();"
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
</div>
</form>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epeople" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
<th>{{labelPrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>
<td>
<div class="btn-group edit-field">
<button [routerLink]="getEditEPeoplePage(epersonDto.eperson.id)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngIf="epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -203,36 +203,6 @@ describe('EPeopleRegistryComponent', () => {
}); });
}); });
describe('toggleEditEPerson', () => {
describe('when you click on first edit eperson button', () => {
beforeEach(fakeAsync(() => {
const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton'));
editButtons[0].triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('editEPerson form is toggled', () => {
const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => {
if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) {
expect(component.isEPersonFormShown).toEqual(false);
} else {
expect(component.isEPersonFormShown).toEqual(true);
}
});
});
it('EPerson search section is hidden', () => {
expect(fixture.debugElement.query(By.css('#search'))).toBeNull();
});
});
});
describe('deleteEPerson', () => { describe('deleteEPerson', () => {
describe('when you click on first delete eperson button', () => { describe('when you click on first delete eperson button', () => {
let ePeopleIdsFoundBeforeDelete; let ePeopleIdsFoundBeforeDelete;

View File

@@ -22,6 +22,7 @@ import { PageInfo } from '../../core/shared/page-info.model';
import { NoContent } from '../../core/shared/NoContent.model'; import { NoContent } from '../../core/shared/NoContent.model';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; 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',
@@ -64,11 +65,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
currentPage: 1 currentPage: 1
}); });
/**
* Whether or not to show the EPerson form
*/
isEPersonFormShown: boolean;
// The search form // The search form
searchForm; searchForm;
@@ -114,17 +110,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
initialisePage() { initialisePage() {
this.searching$.next(true); this.searching$.next(true);
this.isEPersonFormShown = false;
this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); this.search({scope: this.currentSearchScope, query: this.currentSearchQuery});
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null && eperson.id) {
this.isEPersonFormShown = true;
}
}));
this.subs.push(this.ePeople$.pipe( this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => { switchMap((epeople: PaginatedList<EPerson>) => {
if (epeople.pageInfo.totalElements > 0) { if (epeople.pageInfo.totalElements > 0) {
return combineLatest([...epeople.page.map((eperson: EPerson) => { return combineLatest(epeople.page.map((eperson: EPerson) => {
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
map((authorized) => { map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -133,7 +123,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}) })
); );
})]).pipe(map((dtos: EpersonDtoModel[]) => { })).pipe(map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epeople.pageInfo, dtos); return buildPaginatedList(epeople.pageInfo, dtos);
})); }));
} else { } else {
@@ -160,14 +150,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
const query: string = data.query; const query: string = data.query;
const scope: string = data.scope; const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query) { if (query != null && this.currentSearchQuery !== query) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { 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) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
this.currentSearchScope = scope; this.currentSearchScope = scope;
@@ -205,23 +195,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return this.epersonService.getActiveEPerson(); return this.epersonService.getActiveEPerson();
} }
/**
* Start editing the selected EPerson
* @param ePerson
*/
toggleEditEPerson(ePerson: EPerson) {
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
if (ePerson === activeEPerson) {
this.epersonService.cancelEditEPerson();
this.isEPersonFormShown = false;
} else {
this.epersonService.editEPerson(ePerson);
this.isEPersonFormShown = true;
}
});
this.scrollToTop();
}
/** /**
* Deletes EPerson, show notification on success/failure & updates EPeople list * Deletes EPerson, show notification on success/failure & updates EPeople list
*/ */
@@ -242,7 +215,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
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('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage }));
} }
}); });
} }
@@ -264,16 +237,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
scrollToTop() {
(function smoothscroll() {
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
if (currentScroll > 0) {
window.requestAnimationFrame(smoothscroll);
window.scrollTo(0, currentScroll - (currentScroll / 8));
}
})();
}
/** /**
* Reset all input-fields to be empty and search all search * Reset all input-fields to be empty and search all search
*/ */
@@ -284,20 +247,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.search({query: ''}); this.search({query: ''});
} }
/** getEditEPeoplePage(id: string): string {
* This method will set everything to stale, which will cause the lists on this page to update. return getEPersonEditRoute(id);
*/
reset(): void {
this.epersonService.getBrowseEndpoint().pipe(
take(1),
switchMap((href: string) => {
return this.requestService.setStaleByHrefSubstring(href).pipe(
take(1),
);
})
).subscribe(()=>{
this.epersonService.cancelEditEPerson();
this.isEPersonFormShown = false;
});
} }
} }

View File

@@ -1,89 +1,97 @@
<div *ngIf="epersonService.getActiveEPerson() | async; then editheader; else createHeader"></div> <div class="container">
<div class="group-form row">
<div class="col-12">
<ng-template #createHeader> <div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<h4>{{messagePrefix + '.create' | translate}}</h4>
</ng-template>
<ng-template #editheader> <ng-template #createHeader>
<h4>{{messagePrefix + '.edit' | translate}}</h4> <h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ng-template #editHeader>
[formModel]="formModel" <h1 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h1>
[formGroup]="formGroup" </ng-template>
[formLayout]="formLayout"
[displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
<div between class="btn-group ml-1">
<button *ngIf="!isImpersonated" class="btn btn-primary" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-primary" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</div>
<button after class="btn btn-danger delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
</ds-form>
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading> <ds-form [formId]="formId"
[formModel]="formModel"
[formGroup]="formGroup"
[formLayout]="formLayout"
[displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()" type="button" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}
</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" type="button" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
<div *ngIf="canImpersonate$ | async" between class="btn-group">
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</div>
<button *ngIf="canDelete$ | async" after class="btn btn-danger delete-button" type="button" (click)="delete()">
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
</ds-form>
<div *ngIf="epersonService.getActiveEPerson() | async"> <ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading> <div *ngIf="epersonService.getActiveEPerson() | async">
<h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
<ds-pagination <ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
*ngIf="(groups | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(groups | async)?.payload"
[collectionSize]="(groups | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive"> <ds-pagination
<table id="groups" class="table table-striped table-hover table-bordered"> *ngIf="(groups | async)?.payload?.totalElements > 0"
<thead> [paginationOptions]="config"
<tr> [pageInfoState]="(groups | async)?.payload"
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th> [collectionSize]="(groups | async)?.payload?.totalElements"
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th> [hideGear]="true"
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th> [hidePagerWhenSinglePage]="true"
</tr> (pageChange)="onPageChange($event)">
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination> <div class="table-responsive">
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> </ds-pagination>
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
<div> <div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]" <div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button> <div>
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,6 +31,10 @@ import { PaginationServiceStub } from '../../../shared/testing/pagination-servic
import { FindListOptions } from '../../../core/data/find-list-options.model'; import { FindListOptions } from '../../../core/data/find-list-options.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { ValidateEmailNotTaken } from './validators/email-taken.validator';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
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;
@@ -43,6 +47,8 @@ describe('EPersonFormComponent', () => {
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let groupsDataService: GroupDataService; let groupsDataService: GroupDataService;
let epersonRegistrationService: EpersonRegistrationService; let epersonRegistrationService: EpersonRegistrationService;
let route: ActivatedRouteStub;
let router: RouterStub;
let paginationService; let paginationService;
@@ -106,6 +112,9 @@ describe('EPersonFormComponent', () => {
}, },
getEPersonByEmail(email): Observable<RemoteData<EPerson>> { getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null); return createSuccessfulRemoteDataObject$(null);
},
findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
} }
}; };
builderService = Object.assign(getMockFormBuilderService(),{ builderService = Object.assign(getMockFormBuilderService(),{
@@ -182,6 +191,8 @@ describe('EPersonFormComponent', () => {
}); });
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
route = new ActivatedRouteStub();
router = new RouterStub();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
@@ -202,6 +213,8 @@ describe('EPersonFormComponent', () => {
{ 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: Router, useValue: router },
EPeopleRegistryComponent EPeopleRegistryComponent
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -263,24 +276,18 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
describe('firstName, lastName and email should be required', () => { describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => { it('form should be invalid because the firstName is required', () => {
fixture.whenStable().then(() => { expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.valid).toBeFalse(); expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); });
}); it('form should be invalid because the lastName is required', () => {
})); expect(component.formGroup.controls.lastName.valid).toBeFalse();
it('form should be invalid because the lastName is required', waitForAsync(() => { expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
fixture.whenStable().then(() => { });
expect(component.formGroup.controls.lastName.valid).toBeFalse(); it('form should be invalid because the email is required', () => {
expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); expect(component.formGroup.controls.email.valid).toBeFalse();
}); expect(component.formGroup.controls.email.errors.required).toBeTrue();
})); });
it('form should be invalid because the email is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.required).toBeTrue();
});
}));
}); });
describe('after inserting information firstName,lastName and email not required', () => { describe('after inserting information firstName,lastName and email not required', () => {
@@ -290,24 +297,18 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test.com'); component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges(); fixture.detectChanges();
}); });
it('firstName should be valid because the firstName is set', waitForAsync(() => { it('firstName should be valid because the firstName is set', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeTrue(); expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull(); expect(component.formGroup.controls.firstName.errors).toBeNull();
}); });
})); it('lastName should be valid because the lastName is set', () => {
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeTrue(); expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.errors).toBeNull(); expect(component.formGroup.controls.lastName.errors).toBeNull();
}); });
})); it('email should be valid because the email is set', () => {
it('email should be valid because the email is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeTrue(); expect(component.formGroup.controls.email.valid).toBeTrue();
expect(component.formGroup.controls.email.errors).toBeNull(); expect(component.formGroup.controls.email.errors).toBeNull();
}); });
}));
}); });
@@ -316,12 +317,10 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test'); component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges(); fixture.detectChanges();
}); });
it('email should not be valid because the email pattern', waitForAsync(() => { it('email should not be valid because the email pattern', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
}); });
}));
}); });
describe('after already utilized email', () => { describe('after already utilized email', () => {
@@ -336,12 +335,10 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('email should not be valid because email is already taken', waitForAsync(() => { it('email should not be valid because email is already taken', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
}); });
}));
}); });
@@ -393,11 +390,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit a new eperson using the correct values', waitForAsync(() => { it('should emit a new eperson using the correct values', () => {
fixture.whenStable().then(() => { expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
expect(component.submitForm.emit).toHaveBeenCalledWith(expected); });
});
}));
}); });
describe('with an active eperson', () => { describe('with an active eperson', () => {
@@ -428,11 +423,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit the existing eperson using the correct values', waitForAsync(() => { it('should emit the existing eperson using the correct values', () => {
fixture.whenStable().then(() => { expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); });
});
}));
}); });
}); });
@@ -491,16 +484,16 @@ describe('EPersonFormComponent', () => {
}); });
it('the delete button should be active if the eperson can be deleted', () => { it('the delete button should be visible if the ePerson can be deleted', () => {
const deleteButton = fixture.debugElement.query(By.css('.delete-button')); const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false); expect(deleteButton).not.toBeNull();
}); });
it('the delete button should be disabled if the eperson cannot be deleted', () => { it('the delete button should be hidden if the ePerson cannot be deleted', () => {
component.canDelete$ = observableOf(false); component.canDelete$ = observableOf(false);
fixture.detectChanges(); fixture.detectChanges();
const deleteButton = fixture.debugElement.query(By.css('.delete-button')); const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(true); expect(deleteButton).toBeNull();
}); });
it('should call the epersonFormComponent delete when clicked on the button', () => { it('should call the epersonFormComponent delete when clicked on the button', () => {

View File

@@ -38,6 +38,8 @@ import { Registration } from '../../../core/shared/registration.model';
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { ActivatedRoute, Router } from '@angular/router';
import { getEPersonsRoute } from '../../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
@@ -194,6 +196,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
public requestService: RequestService, public requestService: RequestService,
private epersonRegistrationService: EpersonRegistrationService, private epersonRegistrationService: EpersonRegistrationService,
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
protected route: ActivatedRoute,
protected router: Router,
) { ) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson; this.epersonInitial = eperson;
@@ -213,7 +217,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page * This method will initialise the page
*/ */
initialisePage() { initialisePage() {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
observableCombineLatest([ observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`), this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -339,6 +345,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
onCancel() { onCancel() {
this.epersonService.cancelEditEPerson(); this.epersonService.cancelEditEPerson();
this.cancelForm.emit(); this.cancelForm.emit();
void this.router.navigate([getEPersonsRoute()]);
} }
/** /**
@@ -390,6 +397,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
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) }));
this.submitForm.emit(ePersonToCreate); this.submitForm.emit(ePersonToCreate);
this.epersonService.clearEPersonRequests();
void this.router.navigateByUrl(getEPersonsRoute());
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.cancelForm.emit(); this.cancelForm.emit();
@@ -429,6 +438,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) }));
this.submitForm.emit(editedEperson); this.submitForm.emit(editedEperson);
void this.router.navigateByUrl(getEPersonsRoute());
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) }));
this.cancelForm.emit(); this.cancelForm.emit();
@@ -495,6 +505,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
).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) }));
void this.router.navigate([getEPersonsRoute()]);
} else { } else {
this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`); this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`);
} }
@@ -541,16 +552,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} }
} }
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
/** /**
* Checks for the given ePerson if there is already an ePerson in the system with that email * Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case * and shows notification if this is the case

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { RemoteData } from '../../core/data/remote-data';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { ResolvedAction } from '../../core/resolving/resolver.actions';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { Store } from '@ngrx/store';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig<EPerson>[] = [
followLink('groups'),
];
/**
* This class represents a resolver that requests a specific {@link EPerson} before the route is activated
*/
@Injectable({
providedIn: 'root',
})
export class EPersonResolver implements Resolve<RemoteData<EPerson>> {
constructor(
protected ePersonService: EPersonDataService,
protected store: Store<any>,
) {
}
/**
* Method for resolving a {@link EPerson} based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns `Observable<<RemoteData<EPerson>>` Emits the found {@link EPerson} based on the parameters in the current
* route, or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<EPerson>> {
const ePersonRD$: Observable<RemoteData<EPerson>> = this.ePersonService.findById(route.params.id,
true,
false,
...EPERSON_EDIT_FOLLOW_LINKS,
).pipe(
getFirstCompletedRemoteData(),
);
ePersonRD$.subscribe((ePersonRD: RemoteData<EPerson>) => {
this.store.dispatch(new ResolvedAction(state.url, ePersonRD.payload));
});
return ePersonRD$;
}
}

View File

@@ -2,14 +2,14 @@
<div class="group-form row"> <div class="group-form row">
<div class="col-12"> <div class="col-12">
<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,7 +20,7 @@
> >
{{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"
@@ -36,12 +36,11 @@
[displayCancel]="false" [displayCancel]="false"
(submitForm)="onSubmit()"> (submitForm)="onSubmit()">
<div before class="btn-group"> <div before class="btn-group">
<button (click)="onCancel()" <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="groupBeingEdited != null" 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()">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}} <i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button> </button>
</div> </div>

View File

@@ -10,7 +10,6 @@ import {
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
ObservedValueOf,
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf, of as observableOf,
@@ -37,7 +36,7 @@ import {
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/aletr-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, isNotEmpty, hasValueOperator } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -48,6 +47,7 @@ import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator'; import { ValidateGroupExists } from './validators/group-exists.validator';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { getGroupEditRoute, getGroupsRoute } from '../../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-group-form', selector: 'ds-group-form',
@@ -165,19 +165,19 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.canEdit$ = this.groupDataService.getActiveGroup().pipe( this.canEdit$ = this.groupDataService.getActiveGroup().pipe(
hasValueOperator(), hasValueOperator(),
switchMap((group: Group) => { switchMap((group: Group) => {
return observableCombineLatest( return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined),
this.hasLinkedDSO(group), this.hasLinkedDSO(group),
(isAuthorized: ObservedValueOf<Observable<boolean>>, hasLinkedDSO: ObservedValueOf<Observable<boolean>>) => { ]).pipe(
return isAuthorized && !hasLinkedDSO; map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO),
}); );
}) }),
); );
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',
label: groupName, label: groupName,
@@ -215,12 +215,12 @@ export class GroupFormComponent implements OnInit, OnDestroy {
} }
this.subs.push( this.subs.push(
observableCombineLatest( observableCombineLatest([
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) {
@@ -230,12 +230,14 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
if (linkedObject?.name) { if (linkedObject?.name) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); if (!this.formGroup.controls.groupCommunity) {
this.formGroup.patchValue({ this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
groupName: activeGroup.name, this.formGroup.patchValue({
groupCommunity: linkedObject?.name ?? '', groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'), groupCommunity: linkedObject?.name ?? '',
}); groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
} else { } else {
this.formModel = [ this.formModel = [
this.groupName, this.groupName,
@@ -263,7 +265,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
onCancel() { onCancel() {
this.groupDataService.cancelEditGroup(); this.groupDataService.cancelEditGroup();
this.cancelForm.emit(); this.cancelForm.emit();
this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]); void this.router.navigate([getGroupsRoute()]);
} }
/** /**
@@ -310,7 +312,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
const groupSelfLink = rd.payload._links.self.href; const groupSelfLink = rd.payload._links.self.href;
this.setActiveGroupWithLink(groupSelfLink); this.setActiveGroupWithLink(groupSelfLink);
this.groupDataService.clearGroupsRequests(); this.groupDataService.clearGroupsRequests();
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(rd.payload.uuid)); void this.router.navigateByUrl(getGroupEditRoute(rd.payload.uuid));
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name })); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name }));

View File

@@ -1,110 +1,12 @@
<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 id="search" class="border-bottom pb-2"> <h3>{{messagePrefix + '.headMembers' | translate}}</h3>
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0"
<div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
<option value="metadata">{{messagePrefix + '.search.scope.metadata' | translate}}</option>
<option value="email">{{messagePrefix + '.search.scope.email' | translate}}</option>
</select>
</div>
<div class="flex-grow-1 mr-3 ml-3">
<div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input">
<span class="input-group-append">
<button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}</button>
</span>
</div>
</div>
<div>
<button (click)="clearFormAndResetResult();"
class="btn btn-secondary">{{messagePrefix + '.button.see-all' | translate}}</button>
</div>
</form>
<ds-pagination *ngIf="(ePeopleSearchDtos | async)?.totalElements > 0"
[paginationOptions]="configSearch"
[pageInfoState]="(ePeopleSearchDtos | async)"
[collectionSize]="(ePeopleSearchDtos | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="ePerson.memberOfGroup"
(click)="deleteMemberFromGroup(ePerson)"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(ePeopleSearchDtos | async)?.totalElements == 0 && searchDone"
class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-items' | translate}}
</div>
<h4>{{messagePrefix + '.headMembers' | translate}}</h4>
<ds-pagination *ngIf="(ePeopleMembersOfGroupDtos | async)?.totalElements > 0"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="(ePeopleMembersOfGroupDtos | async)" [pageInfoState]="(ePeopleMembersOfGroup | async)"
[collectionSize]="(ePeopleMembersOfGroupDtos | async)?.totalElements" [collectionSize]="(ePeopleMembersOfGroup | async)?.totalElements"
[hideGear]="true" [hideGear]="true"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
@@ -119,32 +21,106 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page"> <tr *ngFor="let eperson of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td> <td class="align-middle">{{eperson.id}}</td>
<td class="align-middle"> <td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)" <a (click)="ePersonDataService.startEditingNewEPerson(eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]"> [routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }} {{ dsoNameService.getName(eperson) }}
</a> </a>
</td> </td>
<td class="align-middle"> <td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/> {{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} {{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="ePerson.memberOfGroup" <button (click)="deleteMemberFromGroup(eperson)"
(click)="deleteMemberFromGroup(ePerson)"
[disabled]="actionConfig.remove.disabled" [disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]" [ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i> <i [ngClass]="actionConfig.remove.icon"></i>
</button> </button>
<button *ngIf="!ePerson.memberOfGroup" </div>
(click)="addMemberToGroup(ePerson)" </td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(ePeopleMembersOfGroup | async) == undefined || (ePeopleMembersOfGroup | async)?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-members-yet' | translate}}
</div>
<h3 id="search" class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h3>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3">
<div class="form-group input-group mr-3">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input">
<span class="input-group-append">
<button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}</button>
</span>
</div>
</div>
<div>
<button (click)="clearFormAndResetResult();"
class="btn btn-secondary">{{messagePrefix + '.button.see-all' | translate}}</button>
</div>
</form>
<ds-pagination *ngIf="(ePeopleSearch | async)?.totalElements > 0"
[paginationOptions]="configSearch"
[pageInfoState]="(ePeopleSearch | async)"
[collectionSize]="(ePeopleSearch | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeopleSearch | async)?.page">
<td class="align-middle">{{eperson.id}}</td>
<td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="addMemberToGroup(eperson)"
[disabled]="actionConfig.add.disabled" [disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]" [ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i> <i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
@@ -156,9 +132,10 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(ePeopleMembersOfGroupDtos | async) == undefined || (ePeopleMembersOfGroupDtos | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" <div *ngIf="(ePeopleSearch | async)?.totalElements == 0 && searchDone"
class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-members-yet' | translate}} {{messagePrefix + '.no-items' | translate}}
</div> </div>
</ng-container> </ng-container>

View File

@@ -17,7 +17,7 @@ import { Group } from '../../../../core/eperson/models/group.model';
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 { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; import { GroupMock } from '../../../../shared/testing/group-mock';
import { MembersListComponent } from './members-list.component'; import { MembersListComponent } from './members-list.component';
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
@@ -39,28 +39,26 @@ describe('MembersListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let allEPersons: EPerson[];
let allGroups: Group[];
let epersonMembers: EPerson[]; let epersonMembers: EPerson[];
let subgroupMembers: Group[]; let epersonNonMembers: EPerson[];
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
activeGroup = GroupMock; activeGroup = GroupMock;
epersonMembers = [EPersonMock2]; epersonMembers = [EPersonMock2];
subgroupMembers = [GroupMock2]; epersonNonMembers = [EPersonMock];
allEPersons = [EPersonMock, EPersonMock2];
allGroups = [GroupMock, GroupMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, epersonNonMembers: epersonNonMembers,
// This method is used to get all the current members
findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { // This method is used to search across *non-members*
searchNonMembers(query: string, group: string): Observable<RemoteData<PaginatedList<EPerson>>> {
if (query === '') { if (query === '') {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons)); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers));
} }
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
}, },
@@ -77,22 +75,22 @@ describe('MembersListComponent', () => {
groupsDataServiceStub = { groupsDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, epersonNonMembers: epersonNonMembers,
allGroups: allGroups,
getActiveGroup(): Observable<Group> { getActiveGroup(): Observable<Group> {
return observableOf(activeGroup); return observableOf(activeGroup);
}, },
getEPersonMembers() { getEPersonMembers() {
return this.epersonMembers; return this.epersonMembers;
}, },
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> { addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable<RestResponse> {
if (query === '') { // Add eperson to list of members
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups)); this.epersonMembers = [...this.epersonMembers, epersonToAdd];
} // Remove eperson from list of non-members
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => {
}, if (eperson.id === epersonToAdd.id) {
addMemberToGroup(parentGroup, eperson: EPerson): Observable<RestResponse> { this.epersonNonMembers.splice(index, 1);
this.epersonMembers = [...this.epersonMembers, eperson]; }
});
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
}, },
clearGroupsRequests() { clearGroupsRequests() {
@@ -105,14 +103,14 @@ describe('MembersListComponent', () => {
return '/access-control/groups/' + group.id; return '/access-control/groups/' + group.id;
}, },
deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> { deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> {
this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => { // Remove eperson from list of members
if (eperson.id !== epersonToDelete.id) { this.epersonMembers.forEach( (eperson: EPerson, index: number) => {
return eperson; if (eperson.id === epersonToDelete.id) {
this.epersonMembers.splice(index, 1);
} }
}); });
if (this.epersonMembers === undefined) { // Add eperson to list of non-members
this.epersonMembers = []; this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete];
}
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
} }
}; };
@@ -160,13 +158,37 @@ describe('MembersListComponent', () => {
expect(comp).toBeDefined(); expect(comp).toBeDefined();
})); }));
it('should show list of eperson members of current active group', () => { describe('current members list', () => {
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); it('should show list of eperson members of current active group', () => {
expect(epersonIdsFound.length).toEqual(1); const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
epersonMembers.map((eperson: EPerson) => { expect(epersonIdsFound.length).toEqual(1);
expect(epersonIdsFound.find((foundEl) => { epersonMembers.map((eperson: EPerson) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid); expect(epersonIdsFound.find((foundEl) => {
})).toBeTruthy(); return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
});
});
it('should show a delete button next to each member', () => {
const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr'));
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
});
describe('if first delete button is pressed', () => {
beforeEach(() => {
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#ePeopleMembersOfGroup tbody .fa-trash-alt'));
deleteButton.nativeElement.click();
fixture.detectChanges();
});
it('then no ePerson remains as a member of the active group.', () => {
const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr'));
expect(epersonsFound.length).toEqual(0);
});
}); });
}); });
@@ -174,76 +196,40 @@ describe('MembersListComponent', () => {
describe('when searching without query', () => { describe('when searching without query', () => {
let epersonsFound: DebugElement[]; let epersonsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
return observableOf(activeGroup.epersons.includes(ePerson));
});
component.search({ scope: 'metadata', query: '' }); component.search({ scope: 'metadata', query: '' });
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
// Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything
// because they don't change the value of activeGroup.epersons)
jasmine.getEnv().allowRespy(true);
spyOn(component, 'isMemberOfGroup').and.callThrough();
})); }));
it('should display all epersons', () => { it('should display only non-members of the group', () => {
expect(epersonsFound.length).toEqual(2); const epersonIdsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr td:first-child'));
expect(epersonIdsFound.length).toEqual(1);
epersonNonMembers.map((eperson: EPerson) => {
expect(epersonIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
});
}); });
describe('if eperson is already a eperson', () => { it('should display an add button next to non-members, not a delete button', () => {
it('should have delete button, else it should have add button', () => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
epersonsFound.map((foundEPersonRowElement: DebugElement) => { const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child')); expect(addButton).not.toBeNull();
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); expect(deleteButton).toBeNull();
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
if (memberIds.includes(epersonId.nativeElement.textContent)) {
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
} else {
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
}
});
}); });
}); });
describe('if first add button is pressed', () => { describe('if first add button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(() => {
const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click(); addButton.nativeElement.click();
tick();
fixture.detectChanges(); fixture.detectChanges();
}));
it('then all the ePersons are member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2);
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
}); });
}); it('then all (two) ePersons are member of the active group. No non-members left', () => {
describe('if first delete button is pressed', () => {
beforeEach(fakeAsync(() => {
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
deleteButton.nativeElement.click();
tick();
fixture.detectChanges();
}));
it('then no ePerson is member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2); expect(epersonsFound.length).toEqual(0);
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
});
}); });
}); });
}); });

View File

@@ -4,28 +4,23 @@ import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
Observable, Observable,
of as observableOf,
Subscription, Subscription,
BehaviorSubject, BehaviorSubject
combineLatest as observableCombineLatest,
ObservedValueOf,
} from 'rxjs'; } from 'rxjs';
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { buildPaginatedList, 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 { import {
getFirstSucceededRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getAllCompletedRemoteData, getAllCompletedRemoteData,
getRemoteDataPayload getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
@@ -34,8 +29,8 @@ import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
*/ */
enum SubKey { enum SubKey {
ActiveGroup, ActiveGroup,
MembersDTO, Members,
SearchResultsDTO, SearchResults,
} }
/** /**
@@ -96,11 +91,11 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* EPeople being displayed in search result, initially all members, after search result of search * EPeople being displayed in search result, initially all members, after search result of search
*/ */
ePeopleSearchDtos: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>(undefined); ePeopleSearch: BehaviorSubject<PaginatedList<EPerson>> = new BehaviorSubject<PaginatedList<EPerson>>(undefined);
/** /**
* List of EPeople members of currently active group being edited * List of EPeople members of currently active group being edited
*/ */
ePeopleMembersOfGroupDtos: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>(undefined); ePeopleMembersOfGroup: BehaviorSubject<PaginatedList<EPerson>> = new BehaviorSubject<PaginatedList<EPerson>>(undefined);
/** /**
* Pagination config used to display the list of EPeople that are result of EPeople search * Pagination config used to display the list of EPeople that are result of EPeople search
@@ -129,7 +124,6 @@ export class MembersListComponent implements OnInit, OnDestroy {
// Current search in edit group - epeople search form // Current search in edit group - epeople search form
currentSearchQuery: string; currentSearchQuery: string;
currentSearchScope: string;
// Whether or not user has done a EPeople search yet // Whether or not user has done a EPeople search yet
searchDone: boolean; searchDone: boolean;
@@ -148,18 +142,17 @@ export class MembersListComponent implements OnInit, OnDestroy {
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
) { ) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata';
} }
ngOnInit(): void { ngOnInit(): void {
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
scope: 'metadata',
query: '', query: '',
})); }));
this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => { this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
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: ''});
} }
})); }));
} }
@@ -171,8 +164,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @private * @private
*/ */
retrieveMembers(page: number): void { retrieveMembers(page: number): void {
this.unsubFrom(SubKey.MembersDTO); this.unsubFrom(SubKey.Members);
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.Members,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
@@ -189,49 +182,12 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd; return rd;
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { getRemoteDataPayload())
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { .subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( this.ePeopleMembersOfGroup.next(paginatedListOfEPersons);
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel;
});
return dto$;
})]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
})); }));
} }
/**
* Whether the given ePerson is a member of the group currently being edited
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
*/
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((group: Group) => {
if (group != null) {
return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1,
elementsPerPage: 9999
})
.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((listEPeopleInGroup: PaginatedList<EPerson>) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)),
map((epeople: EPerson[]) => epeople.length > 0));
} else {
return observableOf(false);
}
}));
}
/** /**
* Unsubscribe from a subscription if it's still subscribed, and remove it from the map of * Unsubscribe from a subscription if it's still subscribed, and remove it from the map of
* active subscriptions * active subscriptions
@@ -248,14 +204,18 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* Deletes a given EPerson from the members list of the group currently being edited * Deletes a given EPerson from the members list of the group currently being edited
* @param ePerson EPerson we want to delete as member from group that is currently being edited * @param eperson EPerson we want to delete as member from group that is currently being edited
*/ */
deleteMemberFromGroup(ePerson: EpersonDtoModel) { deleteMemberFromGroup(eperson: EPerson) {
ePerson.memberOfGroup = false;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.deleteMemberFromGroup(activeGroup, eperson);
this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); this.showNotifications('deleteMember', response, this.dsoNameService.getName(eperson), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
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'));
} }
@@ -264,14 +224,18 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* Adds a given EPerson to the members list of the group currently being edited * Adds a given EPerson to the members list of the group currently being edited
* @param ePerson EPerson we want to add as member to group that is currently being edited * @param eperson EPerson we want to add as member to group that is currently being edited
*/ */
addMemberToGroup(ePerson: EpersonDtoModel) { addMemberToGroup(eperson: EPerson) {
ePerson.memberOfGroup = true;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.addMemberToGroup(activeGroup, eperson);
this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); this.showNotifications('addMember', response, this.dsoNameService.getName(eperson), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
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'));
} }
@@ -279,37 +243,25 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
/** /**
* Search in the EPeople by name, email or metadata * Search all EPeople who are NOT a member of the current group by name, email or metadata
* @param data Contains scope and query param * @param data Contains query param
*/ */
search(data: any) { search(data: any) {
this.unsubFrom(SubKey.SearchResultsDTO); this.unsubFrom(SubKey.SearchResults);
this.subs.set(SubKey.SearchResultsDTO, this.subs.set(SubKey.SearchResults,
this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
switchMap((paginationOptions) => { switchMap((paginationOptions) => {
const query: string = data.query; const query: string = data.query;
const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
this.router.navigate([], {
queryParamsHandling: 'merge'
});
this.currentSearchQuery = query; this.currentSearchQuery = query;
this.paginationService.resetPage(this.configSearch.id); this.paginationService.resetPage(this.configSearch.id);
} }
if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) {
this.router.navigate([], {
queryParamsHandling: 'merge'
});
this.currentSearchScope = scope;
this.paginationService.resetPage(this.configSearch.id);
}
this.searchDone = true; this.searchDone = true;
return this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize elementsPerPage: paginationOptions.pageSize
}); }, false, true);
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
@@ -319,23 +271,9 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd; return rd;
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { getRemoteDataPayload())
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { .subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( this.ePeopleSearch.next(paginatedListOfEPersons);
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel;
});
return dto$;
})]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleSearchDtos.next(paginatedListOfDTOs);
})); }));
} }

View File

@@ -1,6 +1,55 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(subGroups$ | async)?.payload"
[collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(subGroups$ | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
<h4 id="search" class="border-bottom pb-2"> <h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{ <span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups', content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
@@ -62,17 +111,7 @@
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td> <td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)" <button (click)="addSubgroupToGroup(group)"
(click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<span *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</span>
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="addSubgroupToGroup(group)"
class="btn btn-outline-primary btn-sm addButton" class="btn btn-outline-primary btn-sm addButton"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-plus fa-fw"></i> <i class="fas fa-plus fa-fw"></i>
@@ -90,53 +129,4 @@
{{messagePrefix + '.no-items' | translate}} {{messagePrefix + '.no-items' | translate}}
</div> </div>
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(subGroups$ | async)?.payload"
[collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(subGroups$ | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
</ng-container> </ng-container>

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { Observable, of as observableOf, BehaviorSubject } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
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';
@@ -18,19 +18,18 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock';
import { SubgroupsListComponent } from './subgroups-list.component'; import { SubgroupsListComponent } from './subgroups-list.component';
import { import {
createSuccessfulRemoteDataObject$, createSuccessfulRemoteDataObject$
createSuccessfulRemoteDataObject
} from '../../../../shared/remote-data.utils'; } from '../../../../shared/remote-data.utils';
import { RouterMock } from '../../../../shared/mocks/router.mock'; import { RouterMock } from '../../../../shared/mocks/router.mock';
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 { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { map } from 'rxjs/operators';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { 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';
describe('SubgroupsListComponent', () => { describe('SubgroupsListComponent', () => {
let component: SubgroupsListComponent; let component: SubgroupsListComponent;
@@ -39,44 +38,70 @@ describe('SubgroupsListComponent', () => {
let builderService: FormBuilderService; let builderService: FormBuilderService;
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup: Group;
let subgroups: Group[]; let subgroups: Group[];
let allGroups: Group[]; let groupNonMembers: Group[];
let routerStub; let routerStub;
let paginationService; let paginationService;
// Define a new mock activegroup for all tests below
let mockActiveGroup: Group = Object.assign(new Group(), {
handle: null,
subgroups: [GroupMock2],
epersons: [EPersonMock2],
selfRegistered: false,
permanent: false,
_links: {
self: {
href: 'https://rest.api/server/api/eperson/groups/activegroupid',
},
subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' },
object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' },
epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' }
},
_name: 'activegroupname',
id: 'activegroupid',
uuid: 'activegroupid',
type: 'group',
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
activeGroup = GroupMock; activeGroup = mockActiveGroup;
subgroups = [GroupMock2]; subgroups = [GroupMock2];
allGroups = [GroupMock, GroupMock2]; groupNonMembers = [GroupMock];
ePersonDataServiceStub = {}; ePersonDataServiceStub = {};
groupsDataServiceStub = { groupsDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
subgroups$: new BehaviorSubject(subgroups), subgroups: subgroups,
groupNonMembers: groupNonMembers,
getActiveGroup(): Observable<Group> { getActiveGroup(): Observable<Group> {
return observableOf(this.activeGroup); return observableOf(this.activeGroup);
}, },
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.subgroups;
}, },
// This method is used to get all the current subgroups
findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> {
return this.subgroups$.pipe( return createSuccessfulRemoteDataObject$(buildPaginatedList<Group>(new PageInfo(), groupsDataServiceStub.getSubgroups()));
map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));
})
);
}, },
getGroupEditPageRouterLink(group: Group): string { getGroupEditPageRouterLink(group: Group): string {
return '/access-control/groups/' + group.id; return '/access-control/groups/' + group.id;
}, },
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> { // This method is used to get all groups which are NOT currently a subgroup member
searchNonMemberGroups(query: string, group: string): Observable<RemoteData<PaginatedList<Group>>> {
if (query === '') { if (query === '') {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allGroups)); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupNonMembers));
} }
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
}, },
addSubGroupToGroup(parentGroup, subgroup: Group): Observable<RestResponse> { addSubGroupToGroup(parentGroup, subgroupToAdd: Group): Observable<RestResponse> {
this.subgroups$.next([...this.subgroups$.getValue(), subgroup]); // Add group to list of subgroups
this.subgroups = [...this.subgroups, subgroupToAdd];
// Remove group from list of non-members
this.groupNonMembers.forEach( (group: Group, index: number) => {
if (group.id === subgroupToAdd.id) {
this.groupNonMembers.splice(index, 1);
}
});
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
}, },
clearGroupsRequests() { clearGroupsRequests() {
@@ -85,12 +110,15 @@ describe('SubgroupsListComponent', () => {
clearGroupLinkRequests() { clearGroupLinkRequests() {
// empty // empty
}, },
deleteSubGroupFromGroup(parentGroup, subgroup: Group): Observable<RestResponse> { deleteSubGroupFromGroup(parentGroup, subgroupToDelete: Group): Observable<RestResponse> {
this.subgroups$.next(this.subgroups$.getValue().filter((group: Group) => { // Remove group from list of subgroups
if (group.id !== subgroup.id) { this.subgroups.forEach( (group: Group, index: number) => {
return group; if (group.id === subgroupToDelete.id) {
this.subgroups.splice(index, 1);
} }
})); });
// Add group to list of non-members
this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete];
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
} }
}; };
@@ -99,7 +127,7 @@ describe('SubgroupsListComponent', () => {
translateService = getMockTranslateService(); translateService = getMockTranslateService();
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
@@ -137,30 +165,38 @@ describe('SubgroupsListComponent', () => {
expect(comp).toBeDefined(); expect(comp).toBeDefined();
})); }));
it('should show list of subgroups of current active group', () => { describe('current subgroup list', () => {
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); it('should show list of subgroups of current active group', () => {
expect(groupIdsFound.length).toEqual(1); const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
activeGroup.subgroups.map((group: Group) => { expect(groupIdsFound.length).toEqual(1);
expect(groupIdsFound.find((foundEl) => { subgroups.map((group: Group) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); expect(groupIdsFound.find((foundEl) => {
})).toBeTruthy(); return (foundEl.nativeElement.textContent.trim() === group.uuid);
}); })).toBeTruthy();
}); });
});
describe('if first group delete button is pressed', () => {
let groupsFound: DebugElement[]; it('should show a delete button next to each subgroup', () => {
beforeEach(fakeAsync(() => { const subgroupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); subgroupsFound.map((foundGroupRowElement: DebugElement) => {
addButton.triggerEventHandler('click', { const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
preventDefault: () => {/**/ const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
} expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
});
describe('if first group delete button is pressed', () => {
let groupsFound: DebugElement[];
beforeEach(() => {
const deleteButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
deleteButton.nativeElement.click();
fixture.detectChanges();
});
it('then no subgroup remains as a member of the active group', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
expect(groupsFound.length).toEqual(0);
}); });
tick();
fixture.detectChanges();
}));
it('one less subgroup in list from 1 to 0 (of 2 total groups)', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
expect(groupsFound.length).toEqual(0);
}); });
}); });
@@ -169,54 +205,38 @@ describe('SubgroupsListComponent', () => {
let groupsFound: DebugElement[]; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ query: '' }); component.search({ query: '' });
fixture.detectChanges();
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
})); }));
it('should display all groups', () => { it('should display only non-member groups (i.e. groups that are not a subgroup)', () => {
fixture.detectChanges();
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
expect(groupsFound.length).toEqual(2);
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
allGroups.map((group: Group) => { expect(groupIdsFound.length).toEqual(1);
groupNonMembers.map((group: Group) => {
expect(groupIdsFound.find((foundEl: DebugElement) => { expect(groupIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
}); });
describe('if group is already a subgroup', () => { it('should display an add button next to non-member groups, not a delete button', () => {
it('should have delete button, else it should have add button', () => { groupsFound.map((foundGroupRowElement: DebugElement) => {
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).not.toBeNull();
expect(deleteButton).toBeNull();
});
});
describe('if first add button is pressed', () => {
beforeEach(() => {
const addButton: DebugElement = fixture.debugElement.query(By.css('#groupsSearch tbody .fa-plus'));
addButton.nativeElement.click();
fixture.detectChanges(); fixture.detectChanges();
});
it('then all (two) Groups are subgroups of the active group. No non-members left', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; expect(groupsFound.length).toEqual(0);
if (getSubgroups !== undefined && getSubgroups.length > 0) {
groupsFound.map((foundGroupRowElement: DebugElement) => {
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
if (activeGroup.id === groupId.nativeElement.textContent) {
expect(deleteButton).toBeNull();
} else {
expect(deleteButton).not.toBeNull();
}
});
} else {
const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id);
groupsFound.map((foundGroupRowElement: DebugElement) => {
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
if (subgroupIds.includes(groupId.nativeElement.textContent)) {
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
} else {
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
}
});
}
}); });
}); });
}); });

View File

@@ -2,16 +2,15 @@ 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, of as observableOf, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { map, mergeMap, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { 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 { import {
getFirstCompletedRemoteData, getAllCompletedRemoteData,
getFirstSucceededRemoteData, 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';
@@ -103,6 +102,7 @@ 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: ''});
} }
})); }));
} }
@@ -131,47 +131,6 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
})); }));
} }
/**
* Whether or not the given group is a subgroup of the group currently being edited
* @param possibleSubgroup Group that is a possible subgroup (being tested) of the group currently being edited
*/
isSubgroupOfGroup(possibleSubgroup: Group): Observable<boolean> {
return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((activeGroup: Group) => {
if (activeGroup != null) {
if (activeGroup.uuid === possibleSubgroup.uuid) {
return observableOf(false);
} else {
return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, {
currentPage: 1,
elementsPerPage: 9999
})
.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((listTotalGroups: PaginatedList<Group>) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)),
map((groups: Group[]) => groups.length > 0));
}
} else {
return observableOf(false);
}
}));
}
/**
* Whether or not the given group is the current group being edited
* @param group Group that is possibly the current group being edited
*/
isActiveGroup(group: Group): Observable<boolean> {
return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((activeGroup: Group) => {
if (activeGroup != null && activeGroup.uuid === group.uuid) {
return observableOf(true);
}
return observableOf(false);
}));
}
/** /**
* Deletes given subgroup from the group currently being edited * Deletes given subgroup from the group currently being edited
* @param subgroup Group we want to delete from the subgroups of the group currently being edited * @param subgroup Group we want to delete from the subgroups of the group currently being edited
@@ -181,6 +140,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup); const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
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'));
} }
@@ -197,6 +161,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup.uuid !== subgroup.uuid) { if (activeGroup.uuid !== subgroup.uuid) {
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup); const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
// Reload search results (if there is an active query).
// This will potentially remove this added subgroup from search results.
if (this.currentSearchQuery != null) {
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'));
} }
@@ -207,28 +176,38 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
} }
/** /**
* Search in the groups (searches by group name and by uuid exact match) * Search all non-member groups (searches by group name and by uuid exact match). Used to search for
* groups that could be added to current group as a subgroup.
* @param data Contains query param * @param data Contains query param
*/ */
search(data: any) { search(data: any) {
const query: string = data.query;
if (query != null && this.currentSearchQuery !== query) {
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
this.currentSearchQuery = query;
this.configSearch.currentPage = 1;
}
this.searchDone = true;
this.unsubFrom(SubKey.SearchResults); this.unsubFrom(SubKey.SearchResults);
this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( this.subs.set(SubKey.SearchResults,
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, { this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
currentPage: config.currentPage, switchMap((paginationOptions) => {
elementsPerPage: config.pageSize const query: string = data.query;
}, true, true, followLink('object') if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
)) this.currentSearchQuery = query;
).subscribe((rd: RemoteData<PaginatedList<Group>>) => { this.paginationService.resetPage(this.configSearch.id);
this.searchResults$.next(rd); }
})); this.searchDone = true;
return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize
}, false, true, followLink('object'));
}),
getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else {
return rd;
}
}))
.subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.searchResults$.next(rd);
}));
} }
/** /**

View File

@@ -2,17 +2,17 @@
<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]="['newGroup']"> [routerLink]="'create'">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
<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">

View File

@@ -216,18 +216,28 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
/** /**
* Get the members (epersons embedded value of a group) * Get the members (epersons embedded value of a group)
* NOTE: At this time we only grab the *first* member in order to receive the `totalElements` value
* needed for our HTML template.
* @param group * @param group
*/ */
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> { getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1,
elementsPerPage: 1,
}).pipe(getFirstSucceededRemoteData());
} }
/** /**
* Get the subgroups (groups embedded value of a group) * Get the subgroups (groups embedded value of a group)
* NOTE: At this time we only grab the *first* subgroup in order to receive the `totalElements` value
* needed for our HTML template.
* @param group * @param group
*/ */
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> { getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); return this.groupService.findListByHref(group._links.subgroups.href, {
currentPage: 1,
elementsPerPage: 1,
}).pipe(getFirstSucceededRemoteData());
} }
/** /**

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,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

@@ -11,10 +11,14 @@ import { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assuran
import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver'; import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver';
import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component';
import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service';
import { SourceDataResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.reslover';
import { import {
SiteAdministratorGuard SiteAdministratorGuard
} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; } from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver';
import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service';
import {
SourceDataResolver
} from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -25,7 +29,7 @@ import {
component: AdminQualityAssuranceTopicsPageComponent, component: AdminQualityAssuranceTopicsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver, breadcrumb: QualityAssuranceBreadcrumbResolver,
openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver
}, },
data: { data: {
@@ -56,7 +60,7 @@ import {
component: AdminQualityAssuranceEventsPageComponent, component: AdminQualityAssuranceEventsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver, breadcrumb: QualityAssuranceBreadcrumbResolver,
openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver
}, },
data: { data: {
@@ -73,7 +77,9 @@ import {
SourceDataResolver, SourceDataResolver,
AdminQualityAssuranceTopicsPageResolver, AdminQualityAssuranceTopicsPageResolver,
AdminQualityAssuranceEventsPageResolver, AdminQualityAssuranceEventsPageResolver,
AdminQualityAssuranceSourcePageResolver AdminQualityAssuranceSourcePageResolver,
QualityAssuranceBreadcrumbResolver,
QualityAssuranceBreadcrumbService
] ]
}) })
/** /**

View File

@@ -6,7 +6,7 @@ import { AdminNotificationsRoutingModule } from './admin-notifications-routing.m
import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component';
import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component';
import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component';
import {SuggestionNotificationsModule} from '../../suggestion-notifications/suggestion-notifications.module'; import {NotificationsModule} from '../../notifications/notifications.module';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -14,7 +14,7 @@ import {SuggestionNotificationsModule} from '../../suggestion-notifications/sugg
SharedModule, SharedModule,
CoreModule.forRoot(), CoreModule.forRoot(),
AdminNotificationsRoutingModule, AdminNotificationsRoutingModule,
SuggestionNotificationsModule NotificationsModule
], ],
declarations: [ declarations: [
AdminQualityAssuranceTopicsPageComponent, AdminQualityAssuranceTopicsPageComponent,

View File

@@ -3,13 +3,15 @@ import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@a
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { QualityAssuranceSourceObject } from '../../../core/suggestion-notifications/qa/models/quality-assurance-source.model'; import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model';
import { QualityAssuranceSourceService } from '../../../suggestion-notifications/qa/source/quality-assurance-source.service'; import { QualityAssuranceSourceService } from '../../../notifications/qa/source/quality-assurance-source.service';
import {environment} from '../../../../environments/environment';
/** /**
* This class represents a resolver that retrieve the route data before the route is activated. * This class represents a resolver that retrieve the route data before the route is activated.
*/ */
@Injectable() @Injectable()
export class SourceDataResolver implements Resolve<Observable<QualityAssuranceSourceObject[]>> { export class SourceDataResolver implements Resolve<Observable<QualityAssuranceSourceObject[]>> {
private pageSize = environment.qualityAssuranceConfig.pageSize;
/** /**
* Initialize the effect class variables. * Initialize the effect class variables.
* @param {QualityAssuranceSourceService} qualityAssuranceSourceService * @param {QualityAssuranceSourceService} qualityAssuranceSourceService
@@ -25,7 +27,7 @@ export class SourceDataResolver implements Resolve<Observable<QualityAssuranceSo
* @returns Observable<QualityAssuranceSourceObject[]> * @returns Observable<QualityAssuranceSourceObject[]>
*/ */
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<QualityAssuranceSourceObject[]> { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<QualityAssuranceSourceObject[]> {
return this.qualityAssuranceSourceService.getSources(5,0).pipe( return this.qualityAssuranceSourceService.getSources(this.pageSize, 0).pipe(
map((sources: PaginatedList<QualityAssuranceSourceObject>) => { map((sources: PaginatedList<QualityAssuranceSourceObject>) => {
if (sources.page.length === 1) { if (sources.page.length === 1) {
this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]); this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]);

View File

@@ -1,11 +1,11 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<h2 id="sub-header" <h1 id="sub-header"
class="border-bottom mb-2">{{ 'admin.registries.bitstream-formats.create.new' | translate }}</h2> class="border-bottom pb-2">{{ 'admin.registries.bitstream-formats.create.new' | translate }}</h1>
<ds-bitstream-format-form (updatedFormat)="createBitstreamFormat($event)"></ds-bitstream-format-form> <ds-bitstream-format-form (updatedFormat)="createBitstreamFormat($event)"></ds-bitstream-format-form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="bitstream-formats row"> <div class="bitstream-formats row">
<div class="col-12"> <div class="col-12">
<h2 id="header" class="border-bottom pb-2 ">{{'admin.registries.bitstream-formats.head' | translate}}</h2> <h1 id="header" class="border-bottom pb-2">{{'admin.registries.bitstream-formats.head' | translate}}</h1>
<p id="description">{{'admin.registries.bitstream-formats.description' | translate}}</p> <p id="description">{{'admin.registries.bitstream-formats.description' | translate}}</p>
<p id="create-new" class="mb-2"><a [routerLink]="'add'" class="btn btn-success">{{'admin.registries.bitstream-formats.create.new' | translate}}</a></p> <p id="create-new" class="mb-2"><a [routerLink]="'add'" class="btn btn-success">{{'admin.registries.bitstream-formats.create.new' | translate}}</a></p>
@@ -19,7 +19,7 @@
<table id="formats" class="table table-striped table-hover"> <table id="formats" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col"></th> <th scope="col" [attr.aria-label]="'admin.registries.bitstream-formats.select' | translate"></th>
<th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
@@ -31,6 +31,7 @@
<td> <td>
<label class="mb-0"> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[attr.aria-label]="'admin.registries.bitstream-formats.select' | translate"
[checked]="isSelected(bitstreamFormat) | async" [checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)" (change)="selectBitStreamFormat(bitstreamFormat, $event)"
> >

View File

@@ -1,11 +1,11 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<h2 id="sub-header" <h1 id="sub-header"
class="border-bottom mb-2">{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}</h2> class="border-bottom pb-2">{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}</h1>
<ds-bitstream-format-form [bitstreamFormat]="(bitstreamFormatRD$ | async)?.payload" (updatedFormat)="updateFormat($event)"></ds-bitstream-format-form> <ds-bitstream-format-form [bitstreamFormat]="(bitstreamFormatRD$ | async)?.payload" (updatedFormat)="updateFormat($event)"></ds-bitstream-format-form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,9 +8,9 @@ import {
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { take } from 'rxjs/operators'; import { switchMap, take, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { combineLatest } from 'rxjs'; import { Observable, combineLatest } from 'rxjs';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
@Component({ @Component({
@@ -147,30 +147,48 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
* Emit the updated/created schema using the EventEmitter submitForm * Emit the updated/created schema using the EventEmitter submitForm
*/ */
onSubmit(): void { onSubmit(): void {
this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService
this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe( .getActiveMetadataSchema()
(schema: MetadataSchema) => { .pipe(
const values = { take(1),
prefix: this.name.value, switchMap((schema: MetadataSchema) => {
namespace: this.namespace.value const metadataValues = {
}; prefix: this.name.value,
if (schema == null) { namespace: this.namespace.value,
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), values)).subscribe((newSchema) => { };
this.submitForm.emit(newSchema);
let createOrUpdate$: Observable<MetadataSchema>;
if (schema == null) {
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
Object.assign(new MetadataSchema(), metadataValues)
);
} else {
const updatedSchema = Object.assign(
new MetadataSchema(),
schema,
{
namespace: metadataValues.namespace,
}
);
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
updatedSchema
);
}
return createOrUpdate$;
}),
tap(() => {
this.registryService.clearMetadataSchemaRequests().subscribe();
})
)
.subscribe((updatedOrCreatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedOrCreatedSchema);
this.clearFields();
this.registryService.cancelEditMetadataSchema();
}); });
} else {
this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, {
id: schema.id,
prefix: schema.prefix,
namespace: values.namespace,
})).subscribe((updatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedSchema);
});
}
this.clearFields();
this.registryService.cancelEditMetadataSchema();
}
);
} }
/** /**

View File

@@ -3,7 +3,8 @@ import {
DynamicFormControlModel, DynamicFormControlModel,
DynamicFormGroupModel, DynamicFormGroupModel,
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel DynamicInputModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
@@ -51,7 +52,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
/** /**
* A dynamic input model for the scopeNote field * A dynamic input model for the scopeNote field
*/ */
scopeNote: DynamicInputModel; scopeNote: DynamicTextAreaModel;
/** /**
* A list of all dynamic input models * A list of all dynamic input models
@@ -132,11 +133,12 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
maxLength: 'error.validation.metadata.qualifier.max-length', maxLength: 'error.validation.metadata.qualifier.max-length',
}, },
}); });
this.scopeNote = new DynamicInputModel({ this.scopeNote = new DynamicTextAreaModel({
id: 'scopeNote', id: 'scopeNote',
label: scopenote, label: scopenote,
name: 'scopeNote', name: 'scopeNote',
required: false, required: false,
rows: 5,
}); });
this.formModel = [ this.formModel = [
new DynamicFormGroupModel( new DynamicFormGroupModel(

View File

@@ -41,7 +41,7 @@
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td> <td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier" class="mb-0">.</label>{{field.qualifier}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td> <td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service'; import { RegistryService } from '../../../core/registry/registry.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
@@ -32,7 +32,7 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
* A component used for managing all existing metadata fields within the current metadata schema. * A component used for managing all existing metadata fields within the current metadata schema.
* The admin can create, edit or delete metadata fields here. * The admin can create, edit or delete metadata fields here.
*/ */
export class MetadataSchemaComponent implements OnInit { export class MetadataSchemaComponent implements OnInit, OnDestroy {
/** /**
* The metadata schema * The metadata schema
*/ */
@@ -60,7 +60,6 @@ export class MetadataSchemaComponent implements OnInit {
constructor(private registryService: RegistryService, constructor(private registryService: RegistryService,
private route: ActivatedRoute, private route: ActivatedRoute,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private router: Router,
private paginationService: PaginationService, private paginationService: PaginationService,
private translateService: TranslateService) { private translateService: TranslateService) {
@@ -86,7 +85,7 @@ export class MetadataSchemaComponent implements OnInit {
*/ */
private updateFields() { private updateFields() {
this.metadataFields$ = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.metadataFields$ = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => combineLatest(this.metadataSchema$, this.needsUpdate$, observableOf(currentPagination))), switchMap((currentPagination) => combineLatest([this.metadataSchema$, this.needsUpdate$, observableOf(currentPagination)])),
switchMap(([schema, update, currentPagination]: [MetadataSchema, boolean, PaginationComponentOptions]) => { switchMap(([schema, update, currentPagination]: [MetadataSchema, boolean, PaginationComponentOptions]) => {
if (update) { if (update) {
this.needsUpdate$.next(false); this.needsUpdate$.next(false);
@@ -193,10 +192,10 @@ export class MetadataSchemaComponent implements OnInit {
showNotification(success: boolean, amount: number) { showNotification(success: boolean, amount: number) {
const prefix = 'admin.registries.schema.notification'; const prefix = 'admin.registries.schema.notification';
const suffix = success ? 'success' : 'failure'; const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest( const messages = observableCombineLatest([
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount }) this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount })
); ]);
messages.subscribe(([head, content]) => { messages.subscribe(([head, content]) => {
if (success) { if (success) {
this.notificationsService.success(head, content); this.notificationsService.success(head, content);
@@ -207,6 +206,7 @@ export class MetadataSchemaComponent implements OnInit {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config.id);
this.registryService.deselectAllMetadataField();
} }
} }

View File

@@ -12,8 +12,7 @@ import { Router } from '@angular/router';
* Represents a non-expandable section in the admin sidebar * Represents a non-expandable section in the admin sidebar
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-admin-sidebar-section',
selector: 'li[ds-admin-sidebar-section]',
templateUrl: './admin-sidebar-section.component.html', templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'], styleUrls: ['./admin-sidebar-section.component.scss'],

View File

@@ -26,30 +26,29 @@
</div> </div>
</li> </li>
<ng-container *ngFor="let section of (sections | async)"> <li *ngFor="let section of (sections | async)">
<ng-container <ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</ng-container> </li>
</ul> </ul>
</div> </div>
<div class="navbar-nav"> <div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle"> <div class="sidebar-section" id="sidebar-collapse-toggle">
<a class="nav-item nav-link sidebar-section d-flex flex-row flex-nowrap" <button class="nav-item nav-link sidebar-section d-flex flex-row flex-nowrap border-0" type="button"
href="javascript:void(0);"
(click)="toggle($event)" (click)="toggle($event)"
(keyup.space)="toggle($event)" (keyup.space)="toggle($event)"
> >
<div class="shortcut-icon"> <span class="shortcut-icon">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right" <i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i> [title]="'menu.section.icon.pin' | translate"></i>
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left" <i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
[title]="'menu.section.icon.unpin' | translate"></i> [title]="'menu.section.icon.unpin' | translate"></i>
</div> </span>
<div class="sidebar-collapsible"> <span class="sidebar-collapsible text-left">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span> <span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span> <span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
</div> </span>
</a> </button>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -143,7 +143,7 @@ describe('AdminSidebarComponent', () => {
describe('when the collapse link is clicked', () => { describe('when the collapse link is clicked', () => {
beforeEach(() => { beforeEach(() => {
spyOn(menuService, 'toggleMenu'); spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > a')); const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > button'));
sidebarToggler.triggerEventHandler('click', { sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/ preventDefault: () => {/**/
} }

View File

@@ -15,8 +15,7 @@ import { Router } from '@angular/router';
* Represents a expandable section in the sidebar * Represents a expandable section in the sidebar
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-expandable-admin-sidebar-section',
selector: 'li[ds-expandable-admin-sidebar-section]',
templateUrl: './expandable-admin-sidebar-section.component.html', templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'], styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor] animations: [rotate, slide, bgColor]

View File

@@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3>{{'bitstream.download.page' | translate:{ bitstream: dsoNameService.getName((bitstream$ | async)) } }}</h3> <h1 class="h2">{{'bitstream.download.page' | translate:{ bitstream: dsoNameService.getName((bitstream$ | async)) } }}</h1>
<div class="pt-3"> <div class="pt-3">
<button (click)="back()" class="btn btn-outline-secondary"> <button (click)="back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{'bitstream.download.page.back' | translate}} <i class="fas fa-arrow-left"></i> {{'bitstream.download.page.back' | translate}}

View File

@@ -8,7 +8,7 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h3>{{dsoNameService.getName(bitstreamRD?.payload)}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h3> <h1 class="h2">{{dsoNameService.getName(bitstreamRD?.payload)}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h1>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<ng-container *ngVar="(breadcrumbs$ | async) as breadcrumbs"> <ng-container *ngVar="(breadcrumbs$ | async) as breadcrumbs">
<nav *ngIf="(showBreadcrumbs$ | async)" aria-label="breadcrumb" class="nav-breadcrumb"> <nav *ngIf="(showBreadcrumbs$ | async)" aria-label="breadcrumb" class="nav-breadcrumb">
<ol class="container breadcrumb"> <ol class="container breadcrumb my-0">
<ng-container <ng-container
*ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'home.breadcrumbs', url: '/'}"></ng-container> *ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'home.breadcrumbs', url: '/'}"></ng-container>
<ng-container *ngFor="let bc of breadcrumbs; let last = last;"> <ng-container *ngFor="let bc of breadcrumbs; let last = last;">

View File

@@ -4,9 +4,8 @@
.breadcrumb { .breadcrumb {
border-radius: 0; border-radius: 0;
margin-top: calc(-1 * var(--ds-content-spacing)); padding-bottom: calc(var(--ds-content-spacing) / 2);
padding-bottom: var(--ds-content-spacing / 3); padding-top: calc(var(--ds-content-spacing) / 2);
padding-top: var(--ds-content-spacing / 3);
background-color: var(--ds-breadcrumb-bg); background-color: var(--ds-breadcrumb-bg);
} }

View File

@@ -89,11 +89,11 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC);
this.subs.push( this.subs.push(
observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => { observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => {
let lowerLimit = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); let lowerLimit: number = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit);
let upperLimit = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear()); let upperLimit: number = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear());
const options = []; const options: number[] = [];
const oneYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5; const oneYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5;
const fiveYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; const fiveYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
if (lowerLimit <= fiveYearBreak) { if (lowerLimit <= fiveYearBreak) {
lowerLimit -= 10; lowerLimit -= 10;
} else if (lowerLimit <= oneYearBreak) { } else if (lowerLimit <= oneYearBreak) {
@@ -101,7 +101,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
} else { } else {
lowerLimit -= 1; lowerLimit -= 1;
} }
let i = upperLimit; let i: number = upperLimit;
while (i > lowerLimit) { while (i > lowerLimit) {
options.push(i); options.push(i);
if (i <= fiveYearBreak) { if (i <= fiveYearBreak) {

View File

@@ -32,7 +32,7 @@
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<div class="browse-by-metadata w-100"> <div class="browse-by-metadata w-100">
<ds-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100" <ds-themed-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100"
title="{{'browse.title' | translate: title="{{'browse.title' | translate:
{ {
collection: dsoNameService.getName((parent$ | async)?.payload), collection: dsoNameService.getName((parent$ | async)?.payload),
@@ -48,7 +48,7 @@
[startsWithOptions]="startsWithOptions" [startsWithOptions]="startsWithOptions"
(prev)="goPrev()" (prev)="goPrev()"
(next)="goNext()"> (next)="goNext()">
</ds-browse-by> </ds-themed-browse-by>
<ds-themed-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading> <ds-themed-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading>
</div> </div>
</section> </section>

View File

@@ -161,7 +161,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
this.value = ''; this.value = '';
} }
if (typeof params.startsWith === 'string'){ if (params.startsWith === undefined || params.startsWith === '') {
this.startsWith = undefined;
}
if (typeof params.startsWith === 'string'){
this.startsWith = params.startsWith.trim(); this.startsWith = params.startsWith.trim();
} }

View File

@@ -1,4 +1,5 @@
<div class="container"> <div class="container">
<h1>{{ ('browse.taxonomy_' + vocabularyName + '.title') | translate }}</h1>
<div class="mb-3"> <div class="mb-3">
<ds-vocabulary-treeview [vocabularyOptions]=vocabularyOptions <ds-vocabulary-treeview [vocabularyOptions]=vocabularyOptions
[multiSelect]="true" [multiSelect]="true"

View File

@@ -14,6 +14,7 @@ import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/t
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module';
import { FormModule } from '../shared/form/form.module'; import { FormModule } from '../shared/form/form.module';
import { SharedModule } from '../shared/shared.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -35,6 +36,7 @@ const ENTRY_COMPONENTS = [
ComcolModule, ComcolModule,
DsoPageModule, DsoPageModule,
FormModule, FormModule,
SharedModule,
], ],
declarations: [ declarations: [
BrowseBySwitcherComponent, BrowseBySwitcherComponent,

View File

@@ -98,9 +98,8 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> imp
// retrieve all entity types to populate the dropdowns selection // retrieve all entity types to populate the dropdowns selection
entities$.subscribe((entityTypes: ItemType[]) => { entities$.subscribe((entityTypes: ItemType[]) => {
entityTypes entityTypes = entityTypes.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE);
.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE) entityTypes.forEach((type: ItemType, index: number) => {
.forEach((type: ItemType, index: number) => {
this.entityTypeSelection.add({ this.entityTypeSelection.add({
disabled: false, disabled: false,
label: type.label, label: type.label,
@@ -112,7 +111,7 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> imp
} }
}); });
this.formModel = [...collectionFormModels, this.entityTypeSelection]; this.formModel = entityTypes.length === 0 ? collectionFormModels : [...collectionFormModels, this.entityTypeSelection];
super.ngOnInit(); super.ngOnInit();
this.chd.detectChanges(); this.chd.detectChanges();

View File

@@ -6,7 +6,7 @@
<p>{{'collection.edit.item-mapper.description' | translate}}</p> <p>{{'collection.edit.item-mapper.description' | translate}}</p>
<ul ngbNav (navChange)="tabChange($event)" [destroyOnHide]="true" #tabs="ngbNav" class="nav-tabs"> <ul ngbNav (navChange)="tabChange($event)" [destroyOnHide]="true" #tabs="ngbNav" class="nav-tabs">
<li [ngbNavItem]="'browseTab'"> <li [ngbNavItem]="'browseTab'" role="presentation">
<a ngbNavLink>{{'collection.edit.item-mapper.tabs.browse' | translate}}</a> <a ngbNavLink>{{'collection.edit.item-mapper.tabs.browse' | translate}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mt-2"> <div class="mt-2">
@@ -23,7 +23,7 @@
</div> </div>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="'mapTab'"> <li [ngbNavItem]="'mapTab'" role="presentation">
<a ngbNavLink>{{'collection.edit.item-mapper.tabs.map' | translate}}</a> <a ngbNavLink>{{'collection.edit.item-mapper.tabs.map' | translate}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row mt-2"> <div class="row mt-2">

View File

@@ -34,9 +34,6 @@
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="collection"></ds-dso-page-subscription-button>
</div>
</div> </div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<!-- Browse-By Links --> <!-- Browse-By Links -->

View File

@@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso"> <ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4"> <div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'collection.delete.head' | translate}}</h2> <h1 id="header" class="border-bottom pb-2">{{ 'collection.delete.head' | translate}}</h1>
<p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}</p> <p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right space-children-mr"> <div class="col text-right space-children-mr">

View File

@@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3>{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}</h3> <h2>{{'collection.curate.header' |translate:{collection: (collectionName$ |async)} }}</h2>
<ds-curation-form <ds-curation-form
[dsoHandle]="(dsoRD$|async)?.payload.handle" [dsoHandle]="(dsoRD$|async)?.payload.handle"
></ds-curation-form> ></ds-curation-form>

View File

@@ -18,7 +18,7 @@
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button> </button>
</div> </div>
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4> <h2>{{ 'collection.edit.tabs.source.head' | translate }}</h2>
<div *ngIf="contentSource" class="form-check mb-4"> <div *ngIf="contentSource" class="form-check mb-4">
<input type="checkbox" class="form-check-input" id="externalSourceCheck" <input type="checkbox" class="form-check-input" id="externalSourceCheck"
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()"> [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">

View File

@@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD"> <div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
<ng-container *ngIf="itemRD?.hasSucceeded"> <ng-container *ngIf="itemRD?.hasSucceeded">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}</h2> <h1 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}</h1>
<ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata> <ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button> <button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
</ng-container> </ng-container>

View File

@@ -8,7 +8,7 @@ import { ItemTemplateDataService } from '../../core/data/item-template-data.serv
import { getCollectionEditRoute } from '../collection-page-routing-paths'; import { getCollectionEditRoute } from '../collection-page-routing-paths';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { AlertType } from '../../shared/alert/aletr-type'; import { AlertType } from '../../shared/alert/alert-type';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@Component({ @Component({

View File

@@ -1,4 +1,4 @@
<div class="container"> <div class="container">
<h2>{{ 'communityList.title' | translate }}</h2> <h1>{{ 'communityList.title' | translate }}</h1>
<ds-themed-community-list></ds-themed-community-list> <ds-themed-community-list></ds-themed-community-list>
</div> </div>

View File

@@ -24,8 +24,9 @@ import { FlatNode } from './flat-node.model';
import { ShowMoreFlatNode } from './show-more-flat-node.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model'; import { FindListOptions } from '../core/data/find-list-options.model';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
import { v4 as uuidv4 } from 'uuid';
// Helper method to combine an flatten an array of observables of flatNode arrays // Helper method to combine and flatten an array of observables of flatNode arrays
export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> => export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> =>
observableCombineLatest([...obsList]).pipe( observableCombineLatest([...obsList]).pipe(
map((matrix: any[][]) => [].concat(...matrix)), map((matrix: any[][]) => [].concat(...matrix)),
@@ -186,7 +187,7 @@ export class CommunityListService {
return this.transformCommunity(community, level, parent, expandedNodes); return this.transformCommunity(community, level, parent, expandedNodes);
}); });
if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) {
obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; obsList = [...obsList, observableOf([showMoreFlatNode(`community-${uuidv4()}`, level, parent)])];
} }
return combineAndFlatten(obsList); return combineAndFlatten(obsList);
@@ -199,7 +200,7 @@ export class CommunityListService {
* Transforms a community in a list of FlatNodes containing firstly a flatnode of the community itself, * Transforms a community in a list of FlatNodes containing firstly a flatnode of the community itself,
* followed by flatNodes of its possible subcommunities and collection * followed by flatNodes of its possible subcommunities and collection
* It gets called recursively for each subcommunity to add its subcommunities and collections to the list * It gets called recursively for each subcommunity to add its subcommunities and collections to the list
* Number of subcommunities and collections added, is dependant on the current page the parent is at for respectively subcommunities and collections. * Number of subcommunities and collections added, is dependent on the current page the parent is at for respectively subcommunities and collections.
* @param community Community being transformed * @param community Community being transformed
* @param level Depth of the community in the list, subcommunities and collections go one level deeper * @param level Depth of the community in the list, subcommunities and collections go one level deeper
* @param parent Flatnode of the parent community * @param parent Flatnode of the parent community
@@ -257,7 +258,7 @@ export class CommunityListService {
let nodes = rd.payload.page let nodes = rd.payload.page
.map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode));
if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) {
nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; nodes = [...nodes, showMoreFlatNode(`collection-${uuidv4()}`, level + 1, communityFlatNode)];
} }
return nodes; return nodes;
} else { } else {
@@ -275,7 +276,7 @@ export class CommunityListService {
/** /**
* Checks if a community has subcommunities or collections by querying the respective services with a pageSize = 0 * Checks if a community has subcommunities or collections by querying the respective services with a pageSize = 0
* Returns an observable that combines the result.payload.totalElements fo the observables that the * Returns an observable that combines the result.payload.totalElements of the observables that the
* respective services return when queried * respective services return when queried
* @param community Community being checked whether it is expandable (if it has subcommunities or collections) * @param community Community being checked whether it is expandable (if it has subcommunities or collections)
*/ */

View File

@@ -1,5 +1,5 @@
<ds-themed-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-themed-loading> <ds-themed-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-themed-loading>
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl"> <cdk-tree [dataSource]="dataSource" [treeControl]="treeControl" [trackBy]="trackBy">
<!-- This is the tree node template for show more node --> <!-- This is the tree node template for show more node -->
<cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding <cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding
class="example-tree-node show-more-node"> class="example-tree-node show-more-node">
@@ -8,7 +8,7 @@
<span class="fa fa-chevron-right invisible" aria-hidden="true"></span> <span class="fa fa-chevron-right invisible" aria-hidden="true"></span>
</button> </button>
<div class="align-middle pt-2"> <div class="align-middle pt-2">
<button *ngIf="node!==loadingNode" (click)="getNextPage(node)" <button *ngIf="!(dataSource.loading$ | async)" (click)="getNextPage(node)"
class="btn btn-outline-primary btn-sm" role="button"> class="btn btn-outline-primary btn-sm" role="button">
<i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }} <i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }}
</button> </button>
@@ -34,13 +34,13 @@
aria-hidden="true"></span> aria-hidden="true"></span>
</button> </button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<h5 class="align-middle pt-2"> <span class="align-middle pt-2 lead">
<a [routerLink]="node.route" class="lead"> <a [routerLink]="node.route" class="lead">
{{ dsoNameService.getName(node.payload) }} {{ dsoNameService.getName(node.payload) }}
</a> </a>
<span class="pr-2">&nbsp;</span> <span class="pr-2">&nbsp;</span>
<span *ngIf="node.payload.archivedItemsCount >= 0" class="badge badge-pill badge-secondary align-top archived-items-lead">{{node.payload.archivedItemsCount}}</span> <span *ngIf="node.payload.archivedItemsCount >= 0" class="badge badge-pill badge-secondary align-top archived-items-lead">{{node.payload.archivedItemsCount}}</span>
</h5> </span>
</div> </div>
</div> </div>
<ds-truncatable [id]="node.id"> <ds-truncatable [id]="node.id">

View File

@@ -17,6 +17,7 @@ import { By } from '@angular/platform-browser';
import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { FlatNode } from '../flat-node.model'; import { FlatNode } from '../flat-node.model';
import { RouterLinkWithHref } from '@angular/router'; import { RouterLinkWithHref } from '@angular/router';
import { v4 as uuidv4 } from 'uuid';
describe('CommunityListComponent', () => { describe('CommunityListComponent', () => {
let component: CommunityListComponent; let component: CommunityListComponent;
@@ -138,7 +139,7 @@ describe('CommunityListComponent', () => {
} }
if (expandedNodes === null || isEmpty(expandedNodes)) { if (expandedNodes === null || isEmpty(expandedNodes)) {
if (showMoreTopComNode) { if (showMoreTopComNode) {
return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode('community', 0, null)]); return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode(`community-${uuidv4()}`, 0, null)]);
} else { } else {
return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex)); return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex));
} }
@@ -165,21 +166,21 @@ describe('CommunityListComponent', () => {
const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage;
flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)];
if (subComFlatnodes.length > endSubComIndex) { if (subComFlatnodes.length > endSubComIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, topNode.level + 1, expandedParent)];
} }
} }
if (isNotEmpty(collFlatnodes)) { if (isNotEmpty(collFlatnodes)) {
const endColIndex = this.pageSize * expandedParent.currentCollectionPage; const endColIndex = this.pageSize * expandedParent.currentCollectionPage;
flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)];
if (collFlatnodes.length > endColIndex) { if (collFlatnodes.length > endColIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; flatnodes = [...flatnodes, showMoreFlatNode(`collection-${uuidv4()}`, topNode.level + 1, expandedParent)];
} }
} }
} }
} }
}); });
if (showMoreTopComNode) { if (showMoreTopComNode) {
flatnodes = [...flatnodes, showMoreFlatNode('community', 0, null)]; flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, 0, null)];
} }
return observableOf(flatnodes); return observableOf(flatnodes);
} }

View File

@@ -28,10 +28,9 @@ export class CommunityListComponent implements OnInit, OnDestroy {
treeControl = new FlatTreeControl<FlatNode>( treeControl = new FlatTreeControl<FlatNode>(
(node: FlatNode) => node.level, (node: FlatNode) => true (node: FlatNode) => node.level, (node: FlatNode) => true
); );
dataSource: CommunityListDatasource; dataSource: CommunityListDatasource;
paginationConfig: FindListOptions; paginationConfig: FindListOptions;
trackBy = (index, node: FlatNode) => node.id;
constructor( constructor(
protected communityListService: CommunityListService, protected communityListService: CommunityListService,
@@ -58,24 +57,34 @@ export class CommunityListComponent implements OnInit, OnDestroy {
this.communityListService.saveCommunityListStateToStore(this.expandedNodes, this.loadingNode); this.communityListService.saveCommunityListStateToStore(this.expandedNodes, this.loadingNode);
} }
// whether or not this node has children (subcommunities or collections) /**
* Whether this node has children (subcommunities or collections)
* @param _
* @param node
*/
hasChild(_: number, node: FlatNode) { hasChild(_: number, node: FlatNode) {
return node.isExpandable$; return node.isExpandable$;
} }
// whether or not it is a show more node (contains no data, but is indication that there are more topcoms, subcoms or collections /**
* Whether this is a show more node that contains no data, but indicates that there is
* one or more community or collection.
* @param _
* @param node
*/
isShowMore(_: number, node: FlatNode) { isShowMore(_: number, node: FlatNode) {
return node.isShowMoreNode; return node.isShowMoreNode;
} }
/** /**
* Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree so this node is expanded * Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree
* so this node is expanded
* @param node Node we want to expand * @param node Node we want to expand
*/ */
toggleExpanded(node: FlatNode) { toggleExpanded(node: FlatNode) {
this.loadingNode = node; this.loadingNode = node;
if (node.isExpanded) { if (node.isExpanded) {
this.expandedNodes = this.expandedNodes.filter((node2) => node2.name !== node.name); this.expandedNodes = this.expandedNodes.filter((node2) => node2.id !== node.id);
node.isExpanded = false; node.isExpanded = false;
} else { } else {
this.expandedNodes.push(node); this.expandedNodes.push(node);
@@ -92,26 +101,28 @@ export class CommunityListComponent implements OnInit, OnDestroy {
/** /**
* Makes sure the next page of a node is added to the tree (top community, sub community of collection) * Makes sure the next page of a node is added to the tree (top community, sub community of collection)
* > Finds its parent (if not top community) and increases its corresponding collection/subcommunity currentPage * > Finds its parent (if not top community) and increases its corresponding collection/subcommunity
* > Reloads tree with new page added to corresponding top community lis, sub community list or collection list * currentPage
* @param node The show more node indicating whether it's an increase in top communities, sub communities or collections * > Reloads tree with new page added to corresponding top community lis, sub community list or
* collection list
* @param node The show more node indicating whether it's an increase in top communities, sub communities
* or collections
*/ */
getNextPage(node: FlatNode): void { getNextPage(node: FlatNode): void {
this.loadingNode = node; this.loadingNode = node;
if (node.parent != null) { if (node.parent != null) {
if (node.id === 'collection') { if (node.id.startsWith('collection')) {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCollectionPage++; parentNodeInExpandedNodes.currentCollectionPage++;
} }
if (node.id === 'community') { if (node.id.startsWith('community')) {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCommunityPage++; parentNodeInExpandedNodes.currentCommunityPage++;
} }
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} else { } else {
this.paginationConfig.currentPage++; this.paginationConfig.currentPage++;
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} }
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} }
} }

View File

@@ -1,6 +1,6 @@
/** /**
* The show more links in the community tree are also represented by a flatNode so we know where in * The show more links in the community tree are also represented by a flatNode so we know where in
* the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) * the tree it should be rendered and who its parent is (needed for the action resulting in clicking this link)
*/ */
export class ShowMoreFlatNode { export class ShowMoreFlatNode {
} }

View File

@@ -21,9 +21,6 @@
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="communityPayload"></ds-dso-page-subscription-button>
</div>
</div> </div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">

View File

@@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso"> <ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4"> <div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h2> <h1 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h1>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}</p> <p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right space-children-mr"> <div class="col text-right space-children-mr">

View File

@@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3>{{'community.curate.header' |translate:{community: (communityName$ |async)} }}</h3> <h2>{{'community.curate.header' |translate:{community: (communityName$ |async)} }}</h2>
<ds-curation-form <ds-curation-form
[dsoHandle]="(dsoRD$|async)?.payload.handle" [dsoHandle]="(dsoRD$|async)?.payload.handle"
></ds-curation-form> ></ds-curation-form>

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model'; import { Collection } from '../../core/shared/collection.model';
@@ -50,6 +50,8 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
*/ */
subCollectionsRDObs: BehaviorSubject<RemoteData<PaginatedList<Collection>>> = new BehaviorSubject<RemoteData<PaginatedList<Collection>>>({} as any); subCollectionsRDObs: BehaviorSubject<RemoteData<PaginatedList<Collection>>> = new BehaviorSubject<RemoteData<PaginatedList<Collection>>>({} as any);
subscriptions: Subscription[] = [];
constructor( constructor(
protected cds: CollectionDataService, protected cds: CollectionDataService,
protected paginationService: PaginationService, protected paginationService: PaginationService,
@@ -77,7 +79,7 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe( this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => { switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, { return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
@@ -87,11 +89,12 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
}) })
).subscribe((results) => { ).subscribe((results) => {
this.subCollectionsRDObs.next(results); this.subCollectionsRDObs.next(results);
}); }));
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config?.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
@@ -52,6 +52,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
*/ */
subCommunitiesRDObs: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any); subCommunitiesRDObs: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any);
subscriptions: Subscription[] = [];
constructor( constructor(
protected cds: CommunityDataService, protected cds: CommunityDataService,
protected paginationService: PaginationService, protected paginationService: PaginationService,
@@ -79,7 +81,7 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe( this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => { switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, { return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
@@ -89,11 +91,12 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
}) })
).subscribe((results) => { ).subscribe((results) => {
this.subCommunitiesRDObs.next(results); this.subCommunitiesRDObs.next(results);
}); }));
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config?.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -152,12 +152,12 @@ export class AuthInterceptor implements HttpInterceptor {
let authMethodModel: AuthMethod; let authMethodModel: AuthMethod;
if (splittedRealm.length === 1) { if (splittedRealm.length === 1) {
authMethodModel = new AuthMethod(methodName); authMethodModel = new AuthMethod(methodName, Number(j));
authMethodModels.push(authMethodModel); authMethodModels.push(authMethodModel);
} else if (splittedRealm.length > 1) { } else if (splittedRealm.length > 1) {
let location = splittedRealm[1]; let location = splittedRealm[1];
location = this.parseLocation(location); location = this.parseLocation(location);
authMethodModel = new AuthMethod(methodName, location); authMethodModel = new AuthMethod(methodName, Number(j), location);
authMethodModels.push(authMethodModel); authMethodModels.push(authMethodModel);
} }
} }
@@ -165,7 +165,7 @@ export class AuthInterceptor implements HttpInterceptor {
// make sure the email + password login component gets rendered first // make sure the email + password login component gets rendered first
authMethodModels = this.sortAuthMethods(authMethodModels); authMethodModels = this.sortAuthMethods(authMethodModels);
} else { } else {
authMethodModels.push(new AuthMethod(AuthMethodType.Password)); authMethodModels.push(new AuthMethod(AuthMethodType.Password, 0));
} }
return authMethodModels; return authMethodModels;

View File

@@ -598,9 +598,9 @@ describe('authReducer', () => {
authMethods: [], authMethods: [],
idle: false idle: false
}; };
const authMethods = [ const authMethods: AuthMethod[] = [
new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Password, 0),
new AuthMethod(AuthMethodType.Shibboleth, 'location') new AuthMethod(AuthMethodType.Shibboleth, 1, 'location'),
]; ];
const action = new RetrieveAuthMethodsSuccessAction(authMethods); const action = new RetrieveAuthMethodsSuccessAction(authMethods);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
@@ -632,7 +632,7 @@ describe('authReducer', () => {
loaded: false, loaded: false,
blocking: false, blocking: false,
loading: false, loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)], authMethods: [new AuthMethod(AuthMethodType.Password, 0)],
idle: false idle: false
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);

View File

@@ -236,7 +236,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
return Object.assign({}, state, { return Object.assign({}, state, {
loading: false, loading: false,
blocking: false, blocking: false,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password, 0)]
}); });
case AuthActionTypes.SET_REDIRECT_URL: case AuthActionTypes.SET_REDIRECT_URL:

View File

@@ -2,11 +2,12 @@ import { AuthMethodType } from './auth.method-type';
export class AuthMethod { export class AuthMethod {
authMethodType: AuthMethodType; authMethodType: AuthMethodType;
position: number;
location?: string; location?: string;
// isStandalonePage? = true; constructor(authMethodName: string, position: number, location?: string) {
this.position = position;
constructor(authMethodName: string, location?: string) {
switch (authMethodName) { switch (authMethodName) {
case 'ip': { case 'ip': {
this.authMethodType = AuthMethodType.Ip; this.authMethodType = AuthMethodType.Ip;

View File

@@ -50,7 +50,7 @@ export class DSONameService {
} }
}, },
OrgUnit: (dso: DSpaceObject): string => { OrgUnit: (dso: DSpaceObject): string => {
return dso.firstMetadataValue('organization.legalName'); return dso.firstMetadataValue('organization.legalName') || this.translateService.instant('dso.name.untitled');
}, },
Default: (dso: DSpaceObject): string => { Default: (dso: DSpaceObject): string => {
// If object doesn't have dc.title metadata use name property // If object doesn't have dc.title metadata use name property
@@ -106,7 +106,7 @@ export class DSONameService {
} }
return `${familyName}, ${givenName}`; return `${familyName}, ${givenName}`;
} else if (entityType === 'OrgUnit') { } else if (entityType === 'OrgUnit') {
return this.firstMetadataValue(object, dso, 'organization.legalName'); return this.firstMetadataValue(object, dso, 'organization.legalName') || this.translateService.instant('dso.name.untitled');
} }
return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled');
} }

View File

@@ -0,0 +1,31 @@
import {QualityAssuranceBreadcrumbResolver} from './quality-assurance-breadcrumb.resolver';
describe('QualityAssuranceBreadcrumbResolver', () => {
describe('resolve', () => {
let resolver: QualityAssuranceBreadcrumbResolver;
let qualityAssuranceBreadcrumbService: any;
let route: any;
const fullPath = '/test/quality-assurance/';
const expectedKey = 'testSourceId:testTopicId';
beforeEach(() => {
route = {
paramMap: {
get: function (param) {
return this[param];
},
sourceId: 'testSourceId',
topicId: 'testTopicId'
}
};
qualityAssuranceBreadcrumbService = {};
resolver = new QualityAssuranceBreadcrumbResolver(qualityAssuranceBreadcrumbService);
});
it('should resolve the breadcrumb config', () => {
const resolvedConfig = resolver.resolve(route as any, {url: fullPath + 'testSourceId'} as any);
const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath };
expect(resolvedConfig).toEqual(expectedConfig);
});
});
});

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service';
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
@Injectable({
providedIn: 'root'
})
export class QualityAssuranceBreadcrumbResolver implements Resolve<BreadcrumbConfig<string>> {
constructor(protected breadcrumbService: QualityAssuranceBreadcrumbService) {}
/**
* Method that resolve QA item into a breadcrumb
* The parameter are retrieved by the url since part of the QA route config
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns BreadcrumbConfig object
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig<string> {
const sourceId = route.paramMap.get('sourceId');
const topicId = route.paramMap.get('topicId');
let key = sourceId;
if (topicId) {
key += `:${topicId}`;
}
const fullPath = state.url;
const url = fullPath.substr(0, fullPath.indexOf(sourceId));
return { provider: this.breadcrumbService, key, url };
}
}

View File

@@ -0,0 +1,40 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { getTestScheduler } from 'jasmine-marbles';
import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service';
describe('QualityAssuranceBreadcrumbService', () => {
let service: QualityAssuranceBreadcrumbService;
let dataService: any;
let translateService: any = {
instant: (str) => str,
};
let exampleString;
let exampleURL;
let exampleQaKey;
function init() {
exampleString = 'sourceId';
exampleURL = '/test/quality-assurance/';
exampleQaKey = 'admin.quality-assurance.breadcrumbs';
}
beforeEach(waitForAsync(() => {
init();
TestBed.configureTestingModule({}).compileComponents();
}));
beforeEach(() => {
service = new QualityAssuranceBreadcrumbService(dataService,translateService);
});
describe('getBreadcrumbs', () => {
it('should return a breadcrumb based on a string', () => {
const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL);
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL),
new Breadcrumb(exampleString, exampleURL + exampleString)]
});
});
});
});

View File

@@ -0,0 +1,53 @@
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
import { Observable, of as observableOf } from 'rxjs';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { TranslateService } from '@ngx-translate/core';
import { QualityAssuranceTopicDataService } from '../notifications/qa/topics/quality-assurance-topic-data.service';
/**
* Service to calculate QA breadcrumbs for a single part of the route
*/
@Injectable({
providedIn: 'root'
})
export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService<string> {
private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs';
constructor(
protected qualityAssuranceService: QualityAssuranceTopicDataService,
private translationService: TranslateService,
) {
}
/**
* Method to calculate the breadcrumbs
* @param key The key used to resolve the breadcrumb
* @param url The url to use as a link for this breadcrumb
*/
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
const sourceId = key.split(':')[0];
const topicId = key.split(':')[1];
if (topicId) {
return this.qualityAssuranceService.getTopic(topicId).pipe(
getFirstCompletedRemoteData(),
map((topic) => {
return [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
new Breadcrumb(sourceId, `${url}${sourceId}`),
new Breadcrumb(topicId, undefined)];
})
);
} else {
return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
new Breadcrumb(sourceId, `${url}${sourceId}`)]);
}
}
}

View File

@@ -7,6 +7,7 @@ import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
import { RouteEffects } from './services/route.effects'; import { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects'; import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [ export const coreEffects = [
RequestEffects, RequestEffects,
@@ -18,4 +19,5 @@ export const coreEffects = [
ObjectUpdatesEffects, ObjectUpdatesEffects,
RouteEffects, RouteEffects,
RouterEffects, RouterEffects,
MenuEffects,
]; ];

View File

@@ -157,9 +157,9 @@ import { SequenceService } from './shared/sequence.service';
import { CoreState } from './core-state.model'; import { CoreState } from './core-state.model';
import { GroupDataService } from './eperson/group-data.service'; import { GroupDataService } from './eperson/group-data.service';
import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model';
import { QualityAssuranceTopicObject } from './suggestion-notifications/qa/models/quality-assurance-topic.model'; import { QualityAssuranceTopicObject } from './notifications/qa/models/quality-assurance-topic.model';
import { QualityAssuranceEventObject } from './suggestion-notifications/qa/models/quality-assurance-event.model'; import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model';
import { QualityAssuranceSourceObject } from './suggestion-notifications/qa/models/quality-assurance-source.model'; import { QualityAssuranceSourceObject } from './notifications/qa/models/quality-assurance-source.model';
import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model'; import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model';
import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model'; import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model';
import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model'; import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model';

View File

@@ -1,4 +1,3 @@
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';

View File

@@ -11,6 +11,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { EMBED_SEPARATOR } from './base/base-data.service'; import { EMBED_SEPARATOR } from './base/base-data.service';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { environment } from '../../../environments/environment.test';
import { AppConfig } from '../../../config/app-config.interface';
describe('DsoRedirectService', () => { describe('DsoRedirectService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -56,6 +58,7 @@ describe('DsoRedirectService', () => {
}); });
service = new DsoRedirectService( service = new DsoRedirectService(
environment as AppConfig,
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache,
@@ -107,7 +110,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/items/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/items/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to entities route with the corresponding entity type', () => { it('should navigate to entities route with the corresponding entity type', () => {
remoteData.payload.type = 'item'; remoteData.payload.type = 'item';
@@ -124,7 +127,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/entities/publication/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/entities/publication/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to collections route', () => { it('should navigate to collections route', () => {
@@ -133,7 +136,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/collections/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/collections/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to communities route', () => { it('should navigate to communities route', () => {
@@ -142,7 +145,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/communities/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/communities/${remoteData.payload.uuid}`, 301);
}); });
}); });

View File

@@ -6,7 +6,7 @@
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable, Inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
@@ -21,6 +21,7 @@ import { DSpaceObject } from '../shared/dspace-object.model';
import { IdentifiableDataService } from './base/identifiable-data.service'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { getDSORoute } from '../../app-routing-paths'; import { getDSORoute } from '../../app-routing-paths';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
const ID_ENDPOINT = 'pid'; const ID_ENDPOINT = 'pid';
const UUID_ENDPOINT = 'dso'; const UUID_ENDPOINT = 'dso';
@@ -70,6 +71,7 @@ export class DsoRedirectService {
private dataService: DsoByIdOrUUIDDataService; private dataService: DsoByIdOrUUIDDataService;
constructor( constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
@@ -98,7 +100,7 @@ export class DsoRedirectService {
let newRoute = getDSORoute(dso); let newRoute = getDSORoute(dso);
if (hasValue(newRoute)) { if (hasValue(newRoute)) {
// Use a "301 Moved Permanently" redirect for SEO purposes // Use a "301 Moved Permanently" redirect for SEO purposes
this.hardRedirectService.redirect(newRoute, 301); this.hardRedirectService.redirect(this.appConfig.ui.nameSpace.replace(/\/$/, '') + newRoute, 301);
} }
} }
} }

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