Merge branch 'main' into DURACOM-204

# Conflicts:
#	src/app/core/data/feature-authorization/feature-id.ts
This commit is contained in:
Vincenzo Mecca
2024-02-02 11:26:26 +01:00
381 changed files with 12287 additions and 2333 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)

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
import '../support/commands'; import '../support/commands';
@@ -11,8 +11,8 @@ describe('Site Statistics Page', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
// generate 2 view events on an Item's page // generate 2 view events on an Item's page
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
cy.visit('/statistics'); cy.visit('/statistics');

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

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

View File

@@ -1,9 +1,8 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
describe('Item Page', () => { describe('Item Page', () => {
const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION); const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
it('should redirect to the entity page when navigating to an item page', () => { it('should redirect to the entity page when navigating to an item page', () => {

View File

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

View File

@@ -1,4 +1,3 @@
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
const page = { const page = {
@@ -37,7 +36,7 @@ const page = {
describe('Login Modal', () => { describe('Login Modal', () => {
it('should login when clicking button & stay on same page', () => { it('should login when clicking button & stay on same page', () => {
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'));
cy.visit(ENTITYPAGE); cy.visit(ENTITYPAGE);
// Login menu should exist // Login menu should exist
@@ -47,7 +46,7 @@ describe('Login Modal', () => {
page.openLoginMenu(); page.openLoginMenu();
cy.get('.form-login').should('be.visible'); cy.get('.form-login').should('be.visible');
page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('ds-log-in').should('not.exist'); cy.get('ds-log-in').should('not.exist');
// Verify we are still on the same page // Verify we are still on the same page
@@ -67,7 +66,7 @@ describe('Login Modal', () => {
cy.get('.form-login').should('be.visible'); cy.get('.form-login').should('be.visible');
// Login, and the <ds-log-in> tag should no longer exist // Login, and the <ds-log-in> tag should no longer exist
page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('.form-login').should('not.exist'); cy.get('.form-login').should('not.exist');
// Verify we are still on homepage // Verify we are still on homepage
@@ -81,7 +80,7 @@ describe('Login Modal', () => {
it('should support logout', () => { it('should support logout', () => {
// First authenticate & access homepage // First authenticate & access homepage
cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.visit('/'); cy.visit('/');
// Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist
@@ -109,6 +108,9 @@ describe('Login Modal', () => {
cy.get('ds-themed-navbar [data-test="register"]').click(); cy.get('ds-themed-navbar [data-test="register"]').click();
cy.location('pathname').should('eq', '/register'); cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist'); cy.get('ds-register-email').should('exist');
// Test accessibility of this page
testA11y('ds-register-email');
}); });
it('should allow forgot password', () => { it('should allow forgot password', () => {
@@ -123,16 +125,26 @@ describe('Login Modal', () => {
cy.get('ds-themed-navbar [data-test="forgot"]').click(); cy.get('ds-themed-navbar [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot'); cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist'); cy.get('ds-forgot-email').should('exist');
// Test accessibility of this page
testA11y('ds-forgot-email');
}); });
it('should pass accessibility tests', () => { it('should pass accessibility tests in menus', () => {
cy.visit('/'); cy.visit('/');
// Open login menu & verify accessibility
page.openLoginMenu(); page.openLoginMenu();
cy.get('ds-log-in').should('exist'); cy.get('ds-log-in').should('exist');
// Analyze <ds-log-in> for accessibility issues
testA11y('ds-log-in'); testA11y('ds-log-in');
// Now login
page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('ds-log-in').should('not.exist');
// Open user menu, verify user menu accesibility
page.openUserMenu();
cy.get('ds-user-menu').should('be.visible');
testA11y('ds-user-menu');
}); });
}); });

View File

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

View File

@@ -1,5 +1,3 @@
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
const page = { const page = {
fillOutQueryInNavBar(query) { fillOutQueryInNavBar(query) {
// Click the magnifying glass // Click the magnifying glass
@@ -17,7 +15,7 @@ const page = {
describe('Search from Navigation Bar', () => { describe('Search from Navigation Bar', () => {
// NOTE: these tests currently assume this query will return results! // NOTE: these tests currently assume this query will return results!
const query = TEST_SEARCH_TERM; const query = Cypress.env('DSPACE_TEST_SEARCH_TERM');
it('should go to search page with correct query if submitted (from home)', () => { it('should go to search page with correct query if submitted (from home)', () => {
cy.visit('/'); cy.visit('/');

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,7 @@
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
import { v4 as uuidv4 } from 'uuid';
// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
// from the Angular UI's config.json. See 'login()'.
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
export const FALLBACK_TEST_REST_DOMAIN = 'localhost';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs // Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work // ALL custom commands MUST be listed here for code completion to work
@@ -41,6 +37,13 @@ declare global {
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community") * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/ */
generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
/**
* Create a new CSRF token and add to required Cookie. CSRF Token is returned
* in chainable in order to allow it to be sent also in required CSRF header.
* @returns Chainable reference to allow CSRF token to also be sent in header.
*/
createCSRFCookie(): Chainable<any>;
} }
} }
} }
@@ -54,59 +57,32 @@ declare global {
* @param password password to login as * @param password password to login as
*/ */
function login(email: string, password: string): void { function login(email: string, password: string): void {
// Cypress doesn't have access to the running application in Node.js. // Create a fake CSRF cookie/token to use in POST
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI. cy.createCSRFCookie().then((csrfToken: string) => {
// Instead, we'll read our running application's config.json, which contains the configs & // get our REST API's base URL, also needed for POST
// is regenerated at runtime each time the Angular UI application starts up. cy.task('getRestBaseURL').then((baseRestUrl: string) => {
cy.task('readUIConfig').then((str: string) => { // Now, send login POST request including that CSRF token
// Parse config into a JSON object cy.request({
const config = JSON.parse(str); method: 'POST',
url: baseRestUrl + '/api/authn/login',
headers: { [XSRF_REQUEST_HEADER]: csrfToken},
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);
// We expect to have a valid authorization header returned (with our auth token)
expect(resp.headers).to.have.property('authorization');
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. // Initialize our AuthTokenInfo object from the authorization header.
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; const authheader = resp.headers.authorization as string;
if (!config.rest.baseUrl) { const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
//console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
baseRestUrl = config.rest.baseUrl;
}
// Now find domain of our REST API, again with a fallback. // Save our AuthTokenInfo object to our dsAuthInfo UI cookie
let baseDomain = FALLBACK_TEST_REST_DOMAIN; // This ensures the UI will recognize we are logged in on next "visit()"
if (!config.rest.host) { cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); });
} else {
baseDomain = config.rest.host;
}
// Create a fake CSRF Token. Set it in the required server-side cookie
const csrfToken = 'fakeLoginCSRFToken';
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
// Now, send login POST request including that CSRF token
cy.request({
method: 'POST',
url: baseRestUrl + '/api/authn/login',
headers: { [XSRF_REQUEST_HEADER]: csrfToken},
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);
// We expect to have a valid authorization header returned (with our auth token)
expect(resp.headers).to.have.property('authorization');
// Initialize our AuthTokenInfo object from the authorization header.
const authheader = resp.headers.authorization as string;
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie
// This ensures the UI will recognize we are logged in on next "visit()"
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
}); });
// Remove cookie with fake CSRF token, as it's no longer needed
cy.clearCookie(DSPACE_XSRF_COOKIE);
}); });
} }
// Add as a Cypress command (i.e. assign to 'cy.login') // Add as a Cypress command (i.e. assign to 'cy.login')
@@ -141,56 +117,53 @@ Cypress.Commands.add('loginViaForm', loginViaForm);
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community") * @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
*/ */
function generateViewEvent(uuid: string, dsoType: string): void { function generateViewEvent(uuid: string, dsoType: string): void {
// Cypress doesn't have access to the running application in Node.js. // Create a fake CSRF cookie/token to use in POST
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI. cy.createCSRFCookie().then((csrfToken: string) => {
// Instead, we'll read our running application's config.json, which contains the configs & // get our REST API's base URL, also needed for POST
// is regenerated at runtime each time the Angular UI application starts up. cy.task('getRestBaseURL').then((baseRestUrl: string) => {
cy.task('readUIConfig').then((str: string) => { // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
// Parse config into a JSON object cy.request({
const config = JSON.parse(str); method: 'POST',
url: baseRestUrl + '/api/statistics/viewevents',
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. headers: {
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; [XSRF_REQUEST_HEADER] : csrfToken,
if (!config.rest.baseUrl) { // use a known public IP address to avoid being seen as a "bot"
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); 'X-Forwarded-For': '1.1.1.1',
} else { // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot"
baseRestUrl = config.rest.baseUrl; '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
// Now find domain of our REST API, again with a fallback. body: { targetId: uuid, targetType: dsoType },
let baseDomain = FALLBACK_TEST_REST_DOMAIN; }).then((resp) => {
if (!config.rest.host) { // We expect a 201 (which means statistics event was created)
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); expect(resp.status).to.eq(201);
} else { });
baseDomain = config.rest.host;
}
// Create a fake CSRF Token. Set it in the required server-side cookie
const csrfToken = 'fakeGenerateViewEventCSRFToken';
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
// Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
cy.request({
method: 'POST',
url: baseRestUrl + '/api/statistics/viewevents',
headers: {
[XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot"
'X-Forwarded-For': '1.1.1.1',
// 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
body: { targetId: uuid, targetType: dsoType },
}).then((resp) => {
// We expect a 201 (which means statistics event was created)
expect(resp.status).to.eq(201);
}); });
// Remove cookie with fake CSRF token, as it's no longer needed
cy.clearCookie(DSPACE_XSRF_COOKIE);
}); });
} }
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') // Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
Cypress.Commands.add('generateViewEvent', generateViewEvent); Cypress.Commands.add('generateViewEvent', generateViewEvent);
/**
* Can be used by tests to generate a random XSRF/CSRF token and save it to
* the required XSRF/CSRF cookie for usage when sending POST requests or similar.
* The generated CSRF token is returned in a Chainable to allow it to be also sent
* in the CSRF HTTP Header.
* @returns a Cypress Chainable which can be used to get the generated CSRF Token
*/
function createCSRFCookie(): Cypress.Chainable {
// Generate a new token which is a random UUID
const csrfToken: string = uuidv4();
// Save it to our required cookie
cy.task('getRestBaseDomain').then((baseDomain: string) => {
// Create a fake CSRF Token. Set it in the required server-side cookie
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
});
// return the generated token wrapped in a chainable
return cy.wrap(csrfToken);
}
// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie')
Cypress.Commands.add('createCSRFCookie', createCSRFCookie);

View File

@@ -19,45 +19,54 @@ import './commands';
// Import Cypress Axe tools for all tests // Import Cypress Axe tools for all tests
// https://github.com/component-driven/cypress-axe // https://github.com/component-driven/cypress-axe
import 'cypress-axe'; import 'cypress-axe';
import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants';
// Runs once before all tests
before(() => {
// Cypress doesn't have access to the running application in Node.js.
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
// Instead, we'll read our running application's config.json, which contains the configs &
// is regenerated at runtime each time the Angular UI application starts up.
cy.task('readUIConfig').then((str: string) => {
// Parse config into a JSON object
const config = JSON.parse(str);
// Find URL of our REST API & save to global variable via task
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else {
baseRestUrl = config.rest.baseUrl;
}
cy.task('saveRestBaseURL', baseRestUrl);
// Find domain of our REST API & save to global variable via task.
let baseDomain = FALLBACK_TEST_REST_DOMAIN;
if (!config.rest.host) {
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
} else {
baseDomain = config.rest.host;
}
cy.task('saveRestBaseDomain', baseDomain);
});
});
// Runs once before the first test in each "block" // Runs once before the first test in each "block"
beforeEach(() => { beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
// This just ensures it doesn't get in the way of matching other objects in the page. // This just ensures it doesn't get in the way of matching other objects in the page.
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
// Remove any CSRF cookies saved from prior tests
cy.clearCookie(DSPACE_XSRF_COOKIE);
}); });
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. // NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. // from the Angular UI's config.json. See 'before()' above.
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
/*afterEach(() => { const FALLBACK_TEST_REST_DOMAIN = 'localhost';
cy.window().then((win) => {
win.location.href = 'about:blank';
});
});*/
// Global constants used in tests
// May be overridden in our cypress.json config file using specified environment variables.
// Default values listed here are all valid for the Demo Entities Data set available at
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
// (This is the data set used in our CI environment)
// Admin account used for administrative tests
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
// Community/collection/publication used for view/edit tests
export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200';
export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4';
export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
// Search term (should return results) used in search tests
export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test';
// Collection used for submission tests
export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection';
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
// USEFUL REGEX for testing // USEFUL REGEX for testing

View File

@@ -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",
@@ -121,7 +121,7 @@
"ngx-infinite-scroll": "^15.0.0", "ngx-infinite-scroll": "^15.0.0",
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0", "ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^14.0.3", "ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1", "nouislider": "^15.7.1",
"pem": "1.14.7", "pem": "1.14.7",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",

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

View File

@@ -1,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,7 +2,7 @@
<div class="epeople-registry row"> <div class="epeople-registry row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2> <h1 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h1>
<div> <div>
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
@@ -13,9 +13,9 @@
</div> </div>
</div> </div>
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}} <h2 id="search" class="border-bottom pb-2">
{{labelPrefix + 'search.head' | translate}}
</h3> </h2>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div> <div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> <select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">

View File

@@ -201,7 +201,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
deleteEPerson(ePerson: EPerson) { deleteEPerson(ePerson: EPerson) {
if (hasValue(ePerson.id)) { if (hasValue(ePerson.id)) {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = ePerson; modalRef.componentInstance.name = this.dsoNameService.getName(ePerson);
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';

View File

@@ -5,11 +5,11 @@
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div> <div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
</ng-template> </ng-template>
<ng-template #editHeader> <ng-template #editHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h1>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"
@@ -45,7 +45,7 @@
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading> <ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<div *ngIf="epersonService.getActiveEPerson() | async"> <div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5> <h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading> <ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>

View File

@@ -478,7 +478,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
take(1), take(1),
switchMap((eperson: EPerson) => { switchMap((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson; modalRef.componentInstance.name = this.dsoNameService.getName(eperson);
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';

View File

@@ -5,11 +5,11 @@
<div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div> <div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2> <h1 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h1>
</ng-template> </ng-template>
<ng-template #editHeader> <ng-template #editHeader>
<h2 class="border-bottom pb-2"> <h1 class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage', content: 'admin.access-control.groups.form.tooltip.editGroupPage',
@@ -20,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"
@@ -39,9 +39,8 @@
<button (click)="onCancel()" type="button" <button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button> class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div> </div>
<div after *ngIf="(canEdit$ | async) && !groupBeingEdited.permanent" class="btn-group"> <div after *ngIf="(canEdit$ | async) && !groupBeingEdited?.permanent" class="btn-group">
<button class="btn btn-danger delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent" <button (click)="delete()" class="btn btn-danger delete-button" type="button">
(click)="delete()" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}} <i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button> </button>
</div> </div>

View File

@@ -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,
@@ -418,7 +420,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
delete() { delete() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = group; modalRef.componentInstance.name = this.dsoNameService.getName(group);
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header';
modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info';
modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel';

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,104 @@
</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 [routerLink]="getEPersonEditRoute(eperson.id)">
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]"> {{ dsoNameService.getName(eperson) }}
{{ dsoNameService.getName(ePerson.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 [routerLink]="getEPersonEditRoute(eperson.id)">
{{ 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 +130,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(), []));
}, },
@@ -70,29 +68,26 @@ describe('MembersListComponent', () => {
clearLinkRequests() { clearLinkRequests() {
// empty // empty
}, },
getEPeoplePageRouterLink(): string {
return '/access-control/epeople';
}
}; };
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 +100,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 +155,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 +193,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,38 +4,34 @@ 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';
import { getEPersonEditRoute } from '../../../access-control-routing-paths';
/** /**
* Keys to keep track of specific subscriptions * Keys to keep track of specific subscriptions
*/ */
enum SubKey { enum SubKey {
ActiveGroup, ActiveGroup,
MembersDTO, Members,
SearchResultsDTO, SearchResults,
} }
/** /**
@@ -96,11 +92,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 +125,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;
@@ -137,6 +132,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
// current active group being edited // current active group being edited
groupBeingEdited: Group; groupBeingEdited: Group;
readonly getEPersonEditRoute = getEPersonEditRoute;
constructor( constructor(
protected groupDataService: GroupDataService, protected groupDataService: GroupDataService,
public ePersonDataService: EPersonDataService, public ePersonDataService: EPersonDataService,
@@ -148,18 +145,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 +167,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 +185,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 +207,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 +227,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 +246,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 +274,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,7 +2,7 @@
<div class="groups-registry row"> <div class="groups-registry row">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h2> <h1 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h1>
<div> <div>
<button class="mr-auto btn btn-success" <button class="mr-auto btn btn-success"
[routerLink]="'create'"> [routerLink]="'create'">
@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
<h3 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h3> <h2 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h2>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 mr-3">
<div class="form-group input-group"> <div class="form-group input-group">

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.batch-import.page.header' | translate}}</h2> <h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1>
<p>{{'admin.batch-import.page.help' | translate}}</p> <p>{{'admin.batch-import.page.help' | translate}}</p>
<p *ngIf="dso"> <p *ngIf="dso">
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp; selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;

View File

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

@@ -0,0 +1,8 @@
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { getNotificationsModuleRoute } from '../admin-routing-paths';
export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance';
export function getQualityAssuranceRoute(id: string) {
return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString();
}

View File

@@ -0,0 +1,87 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AuthenticatedGuard } from '../../core/auth/authenticated.guard';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service';
import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths';
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 { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service';
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 { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service';
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({
imports: [
RouterModule.forChild([
{
canActivate: [ AuthenticatedGuard ],
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
component: AdminQualityAssuranceTopicsPageComponent,
pathMatch: 'full',
resolve: {
breadcrumb: QualityAssuranceBreadcrumbResolver,
openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver
},
data: {
title: 'admin.quality-assurance.page.title',
breadcrumbKey: 'admin.quality-assurance',
showBreadcrumbsFluid: false
}
},
{
canActivate: [ AuthenticatedGuard ],
path: `${QUALITY_ASSURANCE_EDIT_PATH}`,
component: AdminQualityAssuranceSourcePageComponent,
pathMatch: 'full',
resolve: {
breadcrumb: I18nBreadcrumbResolver,
openaireQualityAssuranceSourceParams: AdminQualityAssuranceSourcePageResolver,
sourceData: SourceDataResolver
},
data: {
title: 'admin.notifications.source.breadcrumbs',
breadcrumbKey: 'admin.notifications.source',
showBreadcrumbsFluid: false
}
},
{
canActivate: [ AuthenticatedGuard ],
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`,
component: AdminQualityAssuranceEventsPageComponent,
pathMatch: 'full',
resolve: {
breadcrumb: QualityAssuranceBreadcrumbResolver,
openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver
},
data: {
title: 'admin.notifications.event.page.title',
breadcrumbKey: 'admin.notifications.event',
showBreadcrumbsFluid: false
}
}
])
],
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
SourceDataResolver,
AdminQualityAssuranceTopicsPageResolver,
AdminQualityAssuranceEventsPageResolver,
AdminQualityAssuranceSourcePageResolver,
QualityAssuranceBreadcrumbResolver,
QualityAssuranceBreadcrumbService
]
})
/**
* Routing module for the Notifications section of the admin sidebar
*/
export class AdminNotificationsRoutingModule {
}

View File

@@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CoreModule } from '../../core/core.module';
import { SharedModule } from '../../shared/shared.module';
import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module';
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 { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component';
import {NotificationsModule} from '../../notifications/notifications.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CoreModule.forRoot(),
AdminNotificationsRoutingModule,
NotificationsModule
],
declarations: [
AdminQualityAssuranceTopicsPageComponent,
AdminQualityAssuranceEventsPageComponent,
AdminQualityAssuranceSourcePageComponent
],
entryComponents: []
})
/**
* This module handles all components related to the notifications pages
*/
export class AdminNotificationsModule {
}

View File

@@ -0,0 +1 @@
<ds-quality-assurance-events></ds-quality-assurance-events>

View File

@@ -0,0 +1,26 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page.component';
describe('AdminQualityAssuranceEventsPageComponent', () => {
let component: AdminQualityAssuranceEventsPageComponent;
let fixture: ComponentFixture<AdminQualityAssuranceEventsPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AdminQualityAssuranceEventsPageComponent ],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminQualityAssuranceEventsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create AdminQualityAssuranceEventsPageComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
/**
* Component for the page that show the QA events related to a specific topic.
*/
@Component({
selector: 'ds-quality-assurance-events-page',
templateUrl: './admin-quality-assurance-events-page.component.html'
})
export class AdminQualityAssuranceEventsPageComponent {
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
/**
* Interface for the route parameters.
*/
export interface AdminQualityAssuranceEventsPageParams {
pageId?: string;
pageSize?: number;
currentPage?: number;
}
/**
* This class represents a resolver that retrieve the route data before the route is activated.
*/
@Injectable()
export class AdminQualityAssuranceEventsPageResolver implements Resolve<AdminQualityAssuranceEventsPageParams> {
/**
* Method for resolving the parameters in the current route.
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns AdminQualityAssuranceEventsPageParams Emits the route parameters
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceEventsPageParams {
return {
pageId: route.queryParams.pageId,
pageSize: parseInt(route.queryParams.pageSize, 10),
currentPage: parseInt(route.queryParams.page, 10)
};
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model';
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.
*/
@Injectable()
export class SourceDataResolver implements Resolve<Observable<QualityAssuranceSourceObject[]>> {
private pageSize = environment.qualityAssuranceConfig.pageSize;
/**
* Initialize the effect class variables.
* @param {QualityAssuranceSourceService} qualityAssuranceSourceService
*/
constructor(
private qualityAssuranceSourceService: QualityAssuranceSourceService,
private router: Router
) { }
/**
* Method for resolving the parameters in the current route.
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<QualityAssuranceSourceObject[]>
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<QualityAssuranceSourceObject[]> {
return this.qualityAssuranceSourceService.getSources(this.pageSize, 0).pipe(
map((sources: PaginatedList<QualityAssuranceSourceObject>) => {
if (sources.page.length === 1) {
this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]);
}
return sources.page;
}));
}
/**
*
* @param route url path
* @returns url path
*/
getResolvedUrl(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/');
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
/**
* Interface for the route parameters.
*/
export interface AdminQualityAssuranceSourcePageParams {
pageId?: string;
pageSize?: number;
currentPage?: number;
}
/**
* This class represents a resolver that retrieve the route data before the route is activated.
*/
@Injectable()
export class AdminQualityAssuranceSourcePageResolver implements Resolve<AdminQualityAssuranceSourcePageParams> {
/**
* Method for resolving the parameters in the current route.
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns AdminQualityAssuranceSourcePageParams Emits the route parameters
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceSourcePageParams {
return {
pageId: route.queryParams.pageId,
pageSize: parseInt(route.queryParams.pageSize, 10),
currentPage: parseInt(route.queryParams.page, 10)
};
}
}

View File

@@ -0,0 +1 @@
<ds-quality-assurance-source></ds-quality-assurance-source>

View File

@@ -0,0 +1,27 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page.component';
describe('AdminQualityAssuranceSourcePageComponent', () => {
let component: AdminQualityAssuranceSourcePageComponent;
let fixture: ComponentFixture<AdminQualityAssuranceSourcePageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AdminQualityAssuranceSourcePageComponent ],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AdminQualityAssuranceSourcePageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create AdminQualityAssuranceSourcePageComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
/**
* Component for the page that show the QA sources.
*/
@Component({
selector: 'ds-admin-quality-assurance-source-page-component',
templateUrl: './admin-quality-assurance-source-page.component.html',
})
export class AdminQualityAssuranceSourcePageComponent {}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
/**
* Interface for the route parameters.
*/
export interface AdminQualityAssuranceTopicsPageParams {
pageId?: string;
pageSize?: number;
currentPage?: number;
}
/**
* This class represents a resolver that retrieve the route data before the route is activated.
*/
@Injectable()
export class AdminQualityAssuranceTopicsPageResolver implements Resolve<AdminQualityAssuranceTopicsPageParams> {
/**
* Method for resolving the parameters in the current route.
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns AdminQualityAssuranceTopicsPageParams Emits the route parameters
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceTopicsPageParams {
return {
pageId: route.queryParams.pageId,
pageSize: parseInt(route.queryParams.pageSize, 10),
currentPage: parseInt(route.queryParams.page, 10)
};
}
}

View File

@@ -0,0 +1 @@
<ds-quality-assurance-topic></ds-quality-assurance-topic>

View File

@@ -0,0 +1,26 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page.component';
describe('AdminQualityAssuranceTopicsPageComponent', () => {
let component: AdminQualityAssuranceTopicsPageComponent;
let fixture: ComponentFixture<AdminQualityAssuranceTopicsPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AdminQualityAssuranceTopicsPageComponent ],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminQualityAssuranceTopicsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create AdminQualityAssuranceTopicsPageComponent', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
/**
* Component for the page that show the QA topics related to a specific source.
*/
@Component({
selector: 'ds-notification-qa-page',
templateUrl: './admin-quality-assurance-topics-page.component.html'
})
export class AdminQualityAssuranceTopicsPageComponent {
}

View File

@@ -1,8 +1,8 @@
<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>

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"><span class="sr-only">{{'admin.registries.bitstream-formats.table.selected' | translate}}</span></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,9 +31,11 @@
<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)"
> >
<span class="sr-only">{{'admin.registries.bitstream-formats.select' | translate}}}</span>
</label> </label>
</td> </td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td>

View File

@@ -1,8 +1,8 @@
<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>

View File

@@ -2,7 +2,7 @@
<div class="metadata-registry row"> <div class="metadata-registry row">
<div class="col-12"> <div class="col-12">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2> <h1 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h1>
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p> <p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
@@ -19,7 +19,7 @@
<table id="metadata-schemas" class="table table-striped table-hover"> <table id="metadata-schemas" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col"></th> <th scope="col"><span class="sr-only">{{'admin.registries.metadata.schemas.table.selected' | translate}}</span></th>
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th> <th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th> <th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th> <th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
@@ -34,6 +34,7 @@
[checked]="isSelected(schema) | async" [checked]="isSelected(schema) | async"
(change)="selectMetadataSchema(schema, $event)" (change)="selectMetadataSchema(schema, $event)"
> >
<span class="sr-only">{{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span>
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td> <td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>

View File

@@ -1,11 +1,11 @@
<div *ngIf="registryService.getActiveMetadataSchema() | async; then editheader; else createHeader"></div> <div *ngIf="registryService.getActiveMetadataSchema() | async; then editheader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h4>{{messagePrefix + '.create' | translate}}</h4> <h2>{{messagePrefix + '.create' | translate}}</h2>
</ng-template> </ng-template>
<ng-template #editheader> <ng-template #editheader>
<h4>{{messagePrefix + '.edit' | translate}}</h4> <h2>{{messagePrefix + '.edit' | translate}}</h2>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"

View File

@@ -1,11 +1,11 @@
<div *ngIf="registryService.getActiveMetadataField() | async; then editheader; else createHeader"></div> <div *ngIf="registryService.getActiveMetadataField() | async; then editheader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h4>{{messagePrefix + '.create' | translate}}</h4> <h2>{{messagePrefix + '.create' | translate}}</h2>
</ng-template> </ng-template>
<ng-template #editheader> <ng-template #editheader>
<h4>{{messagePrefix + '.edit' | translate}}</h4> <h2>{{messagePrefix + '.edit' | translate}}</h2>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"

View File

@@ -2,7 +2,7 @@
<div class="metadata-schema row"> <div class="metadata-schema row">
<div class="col-12" *ngVar="(metadataSchema$ | async) as schema"> <div class="col-12" *ngVar="(metadataSchema$ | async) as schema">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{schema?.prefix}}"</h2> <h1 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{schema?.prefix}}"</h1>
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:{ namespace: schema?.namespace } }}</p> <p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:{ namespace: schema?.namespace } }}</p>
@@ -10,7 +10,7 @@
[metadataSchema]="schema" [metadataSchema]="schema"
(submitForm)="forceUpdateFields()"></ds-metadata-field-form> (submitForm)="forceUpdateFields()"></ds-metadata-field-form>
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3> <h2>{{'admin.registries.schema.fields.head' | translate}}</h2>
<ng-container *ngVar="(metadataFields$ | async)?.payload as fields"> <ng-container *ngVar="(metadataFields$ | async)?.payload as fields">
<ds-pagination <ds-pagination
@@ -24,7 +24,7 @@
<table id="metadata-fields" class="table table-striped table-hover"> <table id="metadata-fields" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th></th> <th><span class="sr-only">{{'admin.registries.schema.fields.table.selected' | translate}}</span></th>
<th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
@@ -33,12 +33,11 @@
<tbody> <tbody>
<tr *ngFor="let field of fields?.page" <tr *ngFor="let field of fields?.page"
[ngClass]="{'table-primary' : isActive(field) | async}"> [ngClass]="{'table-primary' : isActive(field) | async}">
<td> <td *ngVar="(isSelected(field) | async) as selected">
<label class="mb-0"> <input type="checkbox"
<input type="checkbox" [attr.aria-label]="(selected ? 'admin.registries.schema.fields.deselect' : 'admin.registries.schema.fields.select') | translate"
[checked]="isSelected(field) | async" [checked]="selected"
(change)="selectMetadataField(field, $event)"> (change)="selectMetadataField(field, $event)">
</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}}{{field.qualifier ? '.' + field.qualifier : ''}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td>

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

@@ -2,7 +2,12 @@ import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAdminModuleRoute } from '../app-routing-paths'; import { getAdminModuleRoute } from '../app-routing-paths';
export const REGISTRIES_MODULE_PATH = 'registries'; export const REGISTRIES_MODULE_PATH = 'registries';
export const NOTIFICATIONS_MODULE_PATH = 'notifications';
export function getRegistriesModuleRoute() { export function getRegistriesModuleRoute() {
return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString(); return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString();
} }
export function getNotificationsModuleRoute() {
return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString();
}

View File

@@ -6,12 +6,17 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{
path: NOTIFICATIONS_MODULE_PATH,
loadChildren: () => import('./admin-notifications/admin-notifications.module')
.then((m) => m.AdminNotificationsModule),
},
{ {
path: REGISTRIES_MODULE_PATH, path: REGISTRIES_MODULE_PATH,
loadChildren: () => import('./admin-registries/admin-registries.module') loadChildren: () => import('./admin-registries/admin-registries.module')

View File

@@ -34,22 +34,21 @@
</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

@@ -124,7 +124,7 @@ export class WorkspaceItemAdminWorkflowActionsComponent implements OnInit {
*/ */
deleteSupervisionOrder(supervisionOrderEntry: SupervisionOrderListEntry) { deleteSupervisionOrder(supervisionOrderEntry: SupervisionOrderListEntry) {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = supervisionOrderEntry.group; modalRef.componentInstance.name = this.dsoNameService.getName(supervisionOrderEntry.group);
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-supervision.modal.header'; modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-supervision.modal.header';
modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-supervision.modal.info'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-supervision.modal.info';
modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-supervision.modal.cancel'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-supervision.modal.cancel';

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

@@ -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" data-test="browseTab">
<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" data-test="mapTab">
<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

@@ -13,7 +13,7 @@
<!-- Collection logo --> <!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoRD$" <ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload" [logo]="(logoRD$ | async)?.payload"
[alternateText]="'Collection Logo'"> [alternateText]="'collection.logo' | translate">
</ds-comcol-page-logo> </ds-comcol-page-logo>
<!-- Handle --> <!-- Handle -->

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

@@ -1,17 +1,17 @@
<div class="container-fluid mb-2" *ngVar="(itemTemplateRD$ | async) as itemTemplateRD"> <div class="container-fluid mb-2" *ngVar="(itemTemplateRD$ | async) as itemTemplateRD">
<label>{{ 'collection.edit.template.label' | translate}}</label> <span class="d-inline-block mb-2">{{ 'collection.edit.template.label' | translate}}</span>
<div class="button-row space-children-mr"> <div class="button-row space-children-mr">
<button *ngIf="!itemTemplateRD?.payload" class="btn btn-success" (click)="addItemTemplate()"> <button *ngIf="!itemTemplateRD?.payload" class="btn btn-success" (click)="addItemTemplate()">
<i class="fas fa-plus"></i> <i class="fas fa-plus" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.add-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.add-button" | translate}}</span>
</button> </button>
<button *ngIf="itemTemplateRD?.payload" class="btn btn-danger" (click)="deleteItemTemplate()"> <button *ngIf="itemTemplateRD?.payload" class="btn btn-danger" (click)="deleteItemTemplate()">
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.delete-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.delete-button" | translate}}</span>
</button> </button>
<button *ngIf="itemTemplateRD?.payload" class="btn btn-primary" <button *ngIf="itemTemplateRD?.payload" class="btn btn-primary"
[routerLink]="'/collections/' + (dsoRD$ | async)?.payload.uuid + '/itemtemplate'"> [routerLink]="'/collections/' + (dsoRD$ | async)?.payload.uuid + '/itemtemplate'">
<i class="fas fa-edit"></i> <i class="fas fa-edit" aria-hidden="true"></i>
<span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.edit-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"collection.edit.template.edit-button" | translate}}</span>
</button> </button>
</div> </div>

View File

@@ -1,6 +1,6 @@
<div *ngVar="(contentSource$ |async) as contentSource"> <div *ngVar="(contentSource$ |async) as contentSource">
<div class="container-fluid space-children-mr" *ngIf="shouldShow"> <div class="container-fluid space-children-mr" *ngIf="shouldShow">
<h4>{{ 'collection.source.controls.head' | translate }}</h4> <h3>{{ 'collection.source.controls.head' | translate }}</h3>
<div> <div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span> <span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span>
<span>{{contentSource?.harvestStatus}}</span> <span>{{contentSource?.harvestStatus}}</span>

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()">
@@ -26,7 +26,7 @@
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label> for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div> </div>
<ds-themed-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-themed-loading> <ds-themed-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-themed-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4> <h3 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h3>
</div> </div>
<div class="row"> <div class="row">
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)" <ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"

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

@@ -24,6 +24,7 @@ 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 and 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[]> =>
@@ -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);
@@ -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 {

View File

@@ -4,11 +4,11 @@
<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">
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-default" cdkTreeNodeToggle> <span aria-hidden="true" class="btn btn-default invisible" cdkTreeNodeToggle>
<span class="fa fa-chevron-right invisible" aria-hidden="true"></span> <span class="fa fa-chevron-right"></span>
</button> </span>
<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>
@@ -24,20 +24,21 @@
<cdk-tree-node *cdkTreeNodeDef="let node; when: hasChild" cdkTreeNodePadding <cdk-tree-node *cdkTreeNodeDef="let node; when: hasChild" cdkTreeNodePadding
class="example-tree-node expandable-node"> class="example-tree-node expandable-node">
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-default" cdkTreeNodeToggle <button *ngIf="hasChild(null, node) | async" type="button" class="btn btn-default" cdkTreeNodeToggle
[title]="'toggle ' + dsoNameService.getName(node.payload)" [attr.aria-label]="(node.isExpanded ? 'communityList.collapse' : 'communityList.expand') | translate:{ name: dsoNameService.getName(node.payload) }"
[attr.aria-label]="'toggle ' + dsoNameService.getName(node.payload)"
(click)="toggleExpanded(node)" (click)="toggleExpanded(node)"
[ngClass]="(hasChild(null, node)| async) ? 'visible' : 'invisible'" data-test="expand-button">
[attr.data-test]="(hasChild(null, node)| async) ? 'expand-button' : ''">
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" <span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}"
aria-hidden="true"></span> aria-hidden="true"></span>
<span class="sr-only">{{ (node.isExpanded ? 'communityList.collapse' : 'communityList.expand') | translate:{ name: dsoNameService.getName(node.payload) } }}</span>
</button> </button>
<!--Don't render the button when non-expandable otherwise it's still accessible, instead render this placeholder-->
<span *ngIf="!(hasChild(null, node) | async)" aria-hidden="true" class="btn btn-default invisible">
<span class="fa fa-chevron-right"></span>
</span>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<span class="align-middle pt-2 lead"> <span class="align-middle pt-2 lead">
<a [routerLink]="node.route" class="lead"> <a [routerLink]="node.route" class="lead">{{ dsoNameService.getName(node.payload) }}</a>
{{ dsoNameService.getName(node.payload) }}
</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>
</span> </span>
@@ -46,10 +47,9 @@
<ds-truncatable [id]="node.id"> <ds-truncatable [id]="node.id">
<div class="text-muted" cdkTreeNodePadding> <div class="text-muted" cdkTreeNodePadding>
<div class="d-flex" *ngIf="node.payload.shortDescription"> <div class="d-flex" *ngIf="node.payload.shortDescription">
<button type="button" class="btn btn-default invisible"> <span aria-hidden="true" class="btn btn-default invisible">
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" <span class="fa fa-chevron-right"></span>
aria-hidden="true"></span> </span>
</button>
<ds-truncatable-part [id]="node.id" [minLines]="3"> <ds-truncatable-part [id]="node.id" [minLines]="3">
<span>{{node.payload.shortDescription}}</span> <span>{{node.payload.shortDescription}}</span>
</ds-truncatable-part> </ds-truncatable-part>
@@ -58,10 +58,9 @@
</ds-truncatable> </ds-truncatable>
<div class="d-flex" *ngIf="node===loadingNode && dataSource.loading$ | async" <div class="d-flex" *ngIf="node===loadingNode && dataSource.loading$ | async"
cdkTreeNodePadding> cdkTreeNodePadding>
<button type="button" class="btn btn-default invisible"> <span aria-hidden="true" class="btn btn-default invisible">
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" <span class="fa fa-chevron-right"></span>
aria-hidden="true"></span> </span>
</button>
<ds-themed-loading class="ds-themed-loading"></ds-themed-loading> <ds-themed-loading class="ds-themed-loading"></ds-themed-loading>
</div> </div>
</cdk-tree-node> </cdk-tree-node>
@@ -69,22 +68,19 @@
<cdk-tree-node *cdkTreeNodeDef="let node; when: !(hasChild && isShowMore)" cdkTreeNodePadding <cdk-tree-node *cdkTreeNodeDef="let node; when: !(hasChild && isShowMore)" cdkTreeNodePadding
class="example-tree-node childless-node"> class="example-tree-node childless-node">
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-default" cdkTreeNodeToggle> <span aria-hidden="true" class="btn btn-default invisible" cdkTreeNodeToggle>
<span class="fa fa-chevron-right invisible" aria-hidden="true"></span> <span class="fa fa-chevron-right"></span>
</button> </span>
<h6 class="align-middle pt-2"> <h6 class="align-middle pt-2">
<a [routerLink]="node.route" class="lead"> <a [routerLink]="node.route" class="lead">{{ dsoNameService.getName(node.payload) }}</a>
{{ dsoNameService.getName(node.payload) }}
</a>
</h6> </h6>
</div> </div>
<ds-truncatable [id]="node.id"> <ds-truncatable [id]="node.id">
<div class="text-muted" cdkTreeNodePadding> <div class="text-muted" cdkTreeNodePadding>
<div class="d-flex" *ngIf="node.payload.shortDescription"> <div class="d-flex" *ngIf="node.payload.shortDescription">
<button type="button" class="btn btn-default invisible"> <span aria-hidden="true" class="btn btn-default invisible">
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}" <span class="fa fa-chevron-right"></span>
aria-hidden="true"></span> </span>
</button>
<ds-truncatable-part [id]="node.id" [minLines]="3"> <ds-truncatable-part [id]="node.id" [minLines]="3">
<span>{{node.payload.shortDescription}}</span> <span>{{node.payload.shortDescription}}</span>
</ds-truncatable-part> </ds-truncatable-part>

View File

@@ -0,0 +1,4 @@
::ng-deep .fa-chevron-right::before {
display: block;
width: 16px;
}

View File

@@ -5,7 +5,7 @@ import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community
import { CdkTreeModule } from '@angular/cdk/tree'; import { CdkTreeModule } from '@angular/cdk/tree';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
@@ -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);
} }
@@ -299,12 +300,14 @@ describe('CommunityListComponent', () => {
describe('second top community node is expanded and has more children (collections) than page size of collection', () => { describe('second top community node is expanded and has more children (collections) than page size of collection', () => {
describe('children of second top com are added (page-limited pageSize 2)', () => { describe('children of second top com are added (page-limited pageSize 2)', () => {
let allNodes; let allNodes: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
const chevronExpand = fixture.debugElement.queryAll(By.css('.expandable-node button')); const toggleButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.expandable-node button'));
const chevronExpandSpan = fixture.debugElement.queryAll(By.css('.expandable-node button span')); const toggleButtonText: DebugElement = toggleButtons[1].query(By.css('span'));
if (chevronExpandSpan[1].nativeElement.classList.contains('fa-chevron-right')) { expect(toggleButtonText).not.toBeNull();
chevronExpand[1].nativeElement.click();
if (toggleButtonText.nativeElement.classList.contains('fa-chevron-right')) {
toggleButtons[1].nativeElement.click();
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
} }
@@ -314,17 +317,18 @@ describe('CommunityListComponent', () => {
allNodes = [...expandableNodesFound, ...childlessNodesFound]; allNodes = [...expandableNodesFound, ...childlessNodesFound];
})); }));
it('tree contains 2 (page-limited) top com, 2 (page-limited) coll of 2nd top com, a show more for those page-limited coll and show more for page-limited top com', () => { it('tree contains 2 (page-limited) top com, 2 (page-limited) coll of 2nd top com, a show more for those page-limited coll and show more for page-limited top com', () => {
mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { const allNodeNames: string[] = allNodes.map((node: DebugElement) => node.nativeElement.innerText.trim());
expect(allNodes.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === topFlatnode.name);
})).toBeTruthy();
});
mockCollectionsPage1.map((coll) => {
expect(allNodes.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === coll.name);
})).toBeTruthy();
});
expect(allNodes.length).toEqual(4); expect(allNodes.length).toEqual(4);
const flatNodes: string[] = mockTopFlatnodesUnexpanded.slice(0, 2).map((flatNode: FlatNode) => flatNode.name);
for (const flatNode of flatNodes) {
expect(allNodeNames).toContain(flatNode);
}
expect(flatNodes.length).toBe(2);
const page1CollectionNames: string[] = mockCollectionsPage1.map((collection: Collection) => collection.name);
for (const collectionName of page1CollectionNames) {
expect(allNodeNames).toContain(collectionName);
}
expect(page1CollectionNames.length).toBe(2);
const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node'));
expect(showMoreEl.length).toEqual(2); expect(showMoreEl.length).toEqual(2);
}); });

View File

@@ -19,6 +19,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@Component({ @Component({
selector: 'ds-community-list', selector: 'ds-community-list',
templateUrl: './community-list.component.html', templateUrl: './community-list.component.html',
styleUrls: ['./community-list.component.scss'],
}) })
export class CommunityListComponent implements OnInit, OnDestroy { export class CommunityListComponent implements OnInit, OnDestroy {
@@ -84,7 +85,7 @@ export class CommunityListComponent implements OnInit, OnDestroy {
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);
@@ -111,19 +112,18 @@ export class CommunityListComponent implements OnInit, OnDestroy {
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

@@ -7,7 +7,7 @@
<!-- Community name --> <!-- Community name -->
<ds-comcol-page-header [name]="dsoNameService.getName(communityPayload)"></ds-comcol-page-header> <ds-comcol-page-header [name]="dsoNameService.getName(communityPayload)"></ds-comcol-page-header>
<!-- Community logo --> <!-- Community logo -->
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'"> <ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'community.logo' | translate">
</ds-comcol-page-logo> </ds-comcol-page-logo>
<!-- Handle --> <!-- Handle -->
<ds-themed-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'"> <ds-themed-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">

View File

@@ -2,16 +2,16 @@
<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">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)"> <button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}} <i class="fas fa-times" aria-hidden="true"></i> {{'community.delete.cancel' | translate}}
</button> </button>
<button class="btn btn-danger" (click)="onConfirm(dso)" [disabled]="(processing$ | async)"> <button class="btn btn-danger" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'community.delete.processing' | translate}}</span> <span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin' aria-hidden="true"></i> {{'community.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}</span> <span *ngIf="!(processing$ | async)"><i class="fas fa-trash" aria-hidden="true"></i> {{'community.delete.confirm' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>

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

@@ -119,7 +119,7 @@ export class AuthService {
if (hasValue(rd.payload) && rd.payload.authenticated) { if (hasValue(rd.payload) && rd.payload.authenticated) {
return rd.payload; return rd.payload;
} else { } else {
throw (new Error('Invalid email or password')); throw (new Error('auth.errors.invalid-user'));
} }
})); }));

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');
} }

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