Merge remote-tracking branch 'origin/main' into task/main/CST-15074

# Conflicts:
#	src/assets/i18n/en.json5
This commit is contained in:
Vincenzo Mecca
2025-03-06 19:47:38 +01:00
582 changed files with 34355 additions and 12514 deletions

View File

@@ -293,7 +293,8 @@
],
"rules": {
// Custom DSpace Angular rules
"dspace-angular-html/themed-component-usages": "error"
"dspace-angular-html/themed-component-usages": "error",
"dspace-angular-html/no-disabled-attribute-on-button": "error"
}
},
{

298
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,298 @@
#-------------------
# DSpace's dependabot rules. Enables npm updates for all dependencies on a weekly basis
# for main and any maintenance branches. Security updates only apply to main.
#-------------------
version: 2
updates:
###############
## Main branch
###############
# NOTE: At this time, "security-updates" rules only apply if "target-branch" is unspecified
# So, only this first section can include "applies-to: security-updates"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
# Allow up to 10 open PRs for dependencies
open-pull-requests-limit: 10
# Group together Angular package upgrades
groups:
# Group together all minor/patch version updates for Angular in a single PR
angular:
applies-to: version-updates
patterns:
- "@angular*"
update-types:
- "minor"
- "patch"
# Group together all security updates for Angular. Only accept minor/patch types.
angular-security:
applies-to: security-updates
patterns:
- "@angular*"
update-types:
- "minor"
- "patch"
# Group together all minor/patch version updates for NgRx in a single PR
ngrx:
applies-to: version-updates
patterns:
- "@ngrx*"
update-types:
- "minor"
- "patch"
# Group together all security updates for NgRx. Only accept minor/patch types.
ngrx-security:
applies-to: security-updates
patterns:
- "@ngrx*"
update-types:
- "minor"
- "patch"
# Group together all patch version updates for eslint in a single PR
eslint:
applies-to: version-updates
patterns:
- "@typescript-eslint*"
- "eslint*"
update-types:
- "minor"
- "patch"
# Group together all security updates for eslint.
eslint-security:
applies-to: security-updates
patterns:
- "@typescript-eslint*"
- "eslint*"
update-types:
- "minor"
- "patch"
# Group together any testing related version updates
testing:
applies-to: version-updates
patterns:
- "@cypress*"
- "axe-*"
- "cypress*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any testing related security updates
testing-security:
applies-to: security-updates
patterns:
- "@cypress*"
- "axe-*"
- "cypress*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any postcss related version updates
postcss:
applies-to: version-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any postcss related security updates
postcss-security:
applies-to: security-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any sass related version updates
sass:
applies-to: version-updates
patterns:
- "sass*"
update-types:
- "minor"
- "patch"
# Group together any sass related security updates
sass-security:
applies-to: security-updates
patterns:
- "sass*"
update-types:
- "minor"
- "patch"
# Group together any webpack related version updates
webpack:
applies-to: version-updates
patterns:
- "webpack*"
update-types:
- "minor"
- "patch"
# Group together any webpack related seurity updates
webpack-security:
applies-to: security-updates
patterns:
- "webpack*"
update-types:
- "minor"
- "patch"
ignore:
# Ignore all major version updates for all dependencies. We'll only automate minor/patch updates.
- dependency-name: "*"
update-types: ["version-update:semver-major"]
#####################
## dspace-8_x branch
#####################
- package-ecosystem: "npm"
directory: "/"
target-branch: dspace-8_x
schedule:
interval: "weekly"
# Allow up to 10 open PRs for dependencies
open-pull-requests-limit: 10
# Group together Angular package upgrades
groups:
# Group together all patch version updates for Angular in a single PR
angular:
applies-to: version-updates
patterns:
- "@angular*"
update-types:
- "minor"
- "patch"
# Group together all minor/patch version updates for NgRx in a single PR
ngrx:
applies-to: version-updates
patterns:
- "@ngrx*"
update-types:
- "minor"
- "patch"
# Group together all patch version updates for eslint in a single PR
eslint:
applies-to: version-updates
patterns:
- "@typescript-eslint*"
- "eslint*"
update-types:
- "minor"
- "patch"
# Group together any testing related version updates
testing:
applies-to: version-updates
patterns:
- "@cypress*"
- "axe-*"
- "cypress*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any postcss related version updates
postcss:
applies-to: version-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any sass related version updates
sass:
applies-to: version-updates
patterns:
- "sass*"
update-types:
- "minor"
- "patch"
# Group together any webpack related version updates
webpack:
applies-to: version-updates
patterns:
- "webpack*"
update-types:
- "minor"
- "patch"
ignore:
# Ignore all major version updates for all dependencies. We'll only automate minor/patch updates.
- dependency-name: "*"
update-types: ["version-update:semver-major"]
#####################
## dspace-7_x branch
#####################
- package-ecosystem: "npm"
directory: "/"
target-branch: dspace-7_x
schedule:
interval: "weekly"
# Allow up to 10 open PRs for dependencies
open-pull-requests-limit: 10
# Group together Angular package upgrades
groups:
# Group together all minor/patch version updates for Angular in a single PR
angular:
applies-to: version-updates
patterns:
- "@angular*"
update-types:
- "minor"
- "patch"
# Group together all minor/patch version updates for NgRx in a single PR
ngrx:
applies-to: version-updates
patterns:
- "@ngrx*"
update-types:
- "minor"
- "patch"
# Group together all patch version updates for eslint in a single PR
eslint:
applies-to: version-updates
patterns:
- "@typescript-eslint*"
- "eslint*"
update-types:
- "minor"
- "patch"
# Group together any testing related version updates
testing:
applies-to: version-updates
patterns:
- "@cypress*"
- "axe-*"
- "cypress*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any postcss related version updates
postcss:
applies-to: version-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any sass related version updates
sass:
applies-to: version-updates
patterns:
- "sass*"
update-types:
- "minor"
- "patch"
ignore:
# 7.x Cannot update Webpack past v5.76.1 as later versions not supported by Angular 15
# See also https://github.com/DSpace/dspace-angular/pull/3283#issuecomment-2372488489
- dependency-name: "webpack"
# Ignore all major version updates for all dependencies. We'll only automate minor/patch updates.
- dependency-name: "*"
update-types: ["version-update:semver-major"]

View File

@@ -7,7 +7,8 @@ name: Build
on: [push, pull_request]
permissions:
contents: read # to fetch code (actions/checkout)
contents: read # to fetch code (actions/checkout)
packages: read # to fetch private images from GitHub Container Registry (GHCR)
jobs:
tests:
@@ -35,6 +36,9 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096'
# Project name to use when running "docker compose" prior to e2e tests
COMPOSE_PROJECT_NAME: 'ci'
# Docker Registry to use for Docker compose scripts below.
# We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub.
DOCKER_REGISTRY: ghcr.io
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
@@ -114,6 +118,14 @@ jobs:
path: 'coverage/dspace-angular/lcov.info'
retention-days: 14
# Login to our Docker registry, so that we can access private Docker images using "docker compose" below.
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Using "docker compose" start backend using CI configuration
# and load assetstore from a cached copy
- name: Start DSpace REST Backend via Docker (for e2e tests)

View File

@@ -16,7 +16,8 @@ on:
pull_request:
permissions:
contents: read # to fetch code (actions/checkout)
contents: read # to fetch code (actions/checkout)
packages: write # to write images to GitHub Container Registry (GHCR)
jobs:
#############################################################

View File

@@ -1,7 +1,7 @@
# This image will be published as dspace/dspace-angular
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
FROM node:18-alpine
FROM docker.io/node:18-alpine
# Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64
@@ -22,5 +22,5 @@ ENV NODE_OPTIONS="--max_old_space_size=4096"
# Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode
# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
ENV NODE_ENV development
ENV NODE_ENV=development
CMD npm run serve -- --host 0.0.0.0

View File

@@ -4,7 +4,7 @@
# Test build:
# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
FROM node:18-alpine AS build
FROM docker.io/node:18-alpine AS build
# Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64
@@ -26,6 +26,6 @@ COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
WORKDIR /app
USER node
ENV NODE_ENV production
ENV NODE_ENV=production
EXPOSE 4000
CMD pm2-runtime start dspace-ui.json --json

View File

@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start
-----------
**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x`**
**Ensure you're running [Node](https://nodejs.org) `v18.x` or `v20.x`, [npm](https://www.npmjs.com/) >= `v10.x`**
```bash
# clone the repo
@@ -90,7 +90,7 @@ Requirements
------------
- [Node.js](https://nodejs.org)
- Ensure you're running node `v16.x` or `v18.x`
- Ensure you're running node `v18.x` or `v20.x`
If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS.

View File

@@ -30,7 +30,6 @@
"lodash",
"jwt-decode",
"uuid",
"webfontloader",
"zone.js"
],
"outputPath": "dist/browser",

View File

@@ -1,7 +1,7 @@
# NOTE: will log all redux actions and transfers in console
debug: false
# Angular Universal server settings
# Angular User Inteface settings
# NOTE: these settings define where Node.js will start your UI application. Therefore, these
# "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar)
ui:
@@ -17,12 +17,37 @@ ui:
# Trust X-FORWARDED-* headers from proxies (default = true)
useProxies: true
universal:
# Whether to inline "critical" styles into the server-side rendered HTML.
# Determining which styles are critical is a relatively expensive operation;
# this option can be disabled to boost server performance at the expense of
# loading smoothness.
inlineCriticalCss: true
# Angular Server Side Rendering (SSR) settings
ssr:
# Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
# Determining which styles are critical is a relatively expensive operation; this option is
# disabled (false) by default to boost server performance at the expense of loading smoothness.
inlineCriticalCss: false
# Path prefixes to enable SSR for. By default these are limited to paths of primary DSpace objects.
# NOTE: The "/handle/" path ensures Handle redirects work via SSR. The "/reload/" path ensures
# hard refreshes (e.g. after login) trigger SSR while fully reloading the page.
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ]
# Whether to enable rendering of Search component on SSR.
# If set to true the component will be included in the HTML returned from the server side rendering.
# If set to false the component will not be included in the HTML returned from the server side rendering.
enableSearchComponent: false
# Whether to enable rendering of Browse component on SSR.
# If set to true the component will be included in the HTML returned from the server side rendering.
# If set to false the component will not be included in the HTML returned from the server side rendering.
enableBrowseComponent: false
# Enable state transfer from the server-side application to the client-side application.
# Defaults to true.
# Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it.
# Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and
# ensure that users always use the most up-to-date state.
transferState: true
# When a different REST base URL is used for the server-side application, the generated state contains references to
# REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs.
# Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues.
replaceRestUrl: true
# Enable request performance profiling data collection and printing the results in the server console.
# Defaults to false. Enabling in production is NOT recommended
#enablePerformanceProfiler: false
# The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually
@@ -33,6 +58,9 @@ rest:
port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server
# Provide a different REST url to be used during SSR execution. It must contain the whole url including protocol, server port and
# server namespace (uncomment to use it).
#ssrBaseUrl: http://localhost:8080/server
# Caching settings
cache:
@@ -448,6 +476,12 @@ search:
enabled: false
# List of filters to enable in "Advanced Search" dropdown
filter: [ 'title', 'author', 'subject', 'entityType' ]
#
# Number used to render n UI elements called loading skeletons that act as placeholders.
# These elements indicate that some content will be loaded in their stead.
# Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count.
# e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved.
defaultFiltersCount: 5
# Notify metrics
@@ -503,6 +537,16 @@ notifyMetrics:
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
# Live Region configuration
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
# Live regions are perceivable regions of a web page that are typically updated as a
# result of an external event when user focus may be elsewhere.
#
# The DSpace live region is a component present at the bottom of all pages that is invisible by default, but is useful
# for screen readers. Any message pushed to the live region will be announced by the screen reader. These messages
# usually contain information about changes on the page that might not be in focus.
liveRegion:
# The duration after which messages disappear from the live region in milliseconds
messageTimeOutDurationMs: 30000
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
isVisible: false

View File

@@ -1,6 +1,7 @@
import { defineConfig } from 'cypress';
export default defineConfig({
video: true,
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
@@ -18,6 +19,7 @@ export default defineConfig({
// Admin account used for administrative tests
DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com',
DSPACE_TEST_ADMIN_USER_UUID: '335647b6-8a52-4ecb-a8c1-7ebabb199bda',
DSPACE_TEST_ADMIN_PASSWORD: 'dspace',
// Community/collection/publication used for view/edit tests
DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4',
@@ -33,6 +35,8 @@ export default defineConfig({
// Account used to test basic submission process
DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',
// Administrator users group
DSPACE_ADMINISTRATOR_GROUP: 'e59f5659-bff9-451e-b28f-439e7bd467e4'
},
e2e: {
// Setup our plugins for e2e tests

View File

@@ -0,0 +1,54 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Add New Modals', () => {
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('Add new Community modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-new-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-new-title"]').click();
cy.get('a[data-test="menu.section.new_community"]').click();
// Analyze <ds-create-community-parent-selector> for accessibility
testA11y('ds-create-community-parent-selector');
});
it('Add new Collection modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-new-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-new-title"]').click();
cy.get('a[data-test="menu.section.new_collection"]').click();
// Analyze <ds-create-collection-parent-selector> for accessibility
testA11y('ds-create-collection-parent-selector');
});
it('Add new Item modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-new-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-new-title"]').click();
cy.get('a[data-test="menu.section.new_item"]').click();
// Analyze <ds-create-item-parent-selector> for accessibility
testA11y('ds-create-item-parent-selector');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Curation Tasks', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/curation-tasks');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-admin-curation-task').should('be.visible');
// Analyze <ds-admin-curation-task> for accessibility issues
testA11y('ds-admin-curation-task');
});
});

View File

@@ -0,0 +1,54 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Edit Modals', () => {
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('Edit Community modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-edit-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-edit-title"]').click();
cy.get('a[data-test="menu.section.edit_community"]').click();
// Analyze <ds-edit-community-selector> for accessibility
testA11y('ds-edit-community-selector');
});
it('Edit Collection modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-edit-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-edit-title"]').click();
cy.get('a[data-test="menu.section.edit_collection"]').click();
// Analyze <ds-edit-collection-selector> for accessibility
testA11y('ds-edit-collection-selector');
});
it('Edit Item modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-edit-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-edit-title"]').click();
cy.get('a[data-test="menu.section.edit_item"]').click();
// Analyze <ds-edit-item-selector> for accessibility
testA11y('ds-edit-item-selector');
});
});

View File

@@ -0,0 +1,39 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Export Modals', () => {
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('Export metadata modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-export-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-export-title"]').click();
cy.get('a[data-test="menu.section.export_metadata"]').click();
// Analyze <ds-export-metadata-selector> for accessibility
testA11y('ds-export-metadata-selector');
});
it('Export batch modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-export-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-export-title"]').click();
cy.get('a[data-test="menu.section.export_batch"]').click();
// Analyze <ds-export-batch-selector> for accessibility
testA11y('ds-export-batch-selector');
});
});

View File

@@ -0,0 +1,17 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Notifications Publication Claim Page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/notifications/publication-claim');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
//Page must first be visible
cy.get('ds-admin-notifications-publication-claim-page').should('be.visible');
// Analyze <ds-admin-notifications-publication-claim-page> for accessibility issues
testA11y('ds-admin-notifications-publication-claim-page');
});
});

View File

@@ -0,0 +1,21 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Search Page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/search');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
//Page must first be visible
cy.get('ds-admin-search-page').should('be.visible');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-admin-search-page> for accessibility issues
testA11y('ds-admin-search-page');
});
});

View File

@@ -10,7 +10,7 @@ describe('Admin Sidebar', () => {
it('should be pinnable and pass accessibility tests', () => {
// Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click();
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on every expandable section to open all menus
cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true });

View File

@@ -0,0 +1,21 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Workflow Page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/workflow');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-admin-workflow-page').should('be.visible');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-admin-workflow-page> for accessibility issues
testA11y('ds-admin-workflow-page');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Batch Import Page', () => {
beforeEach(() => {
// Must login as an Admin to see processes
cy.visit('/admin/batch-import');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Batch import form must first be visible
cy.get('ds-batch-import-page').should('be.visible');
// Analyze <ds-batch-import-page> for accessibility issues
testA11y('ds-batch-import-page');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Bitstreams Formats', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/registries/bitstream-formats');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-bitstream-formats').should('be.visible');
// Analyze <ds-bitstream-formats> for accessibility issues
testA11y('ds-bitstream-formats');
});
});

View File

@@ -0,0 +1,31 @@
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Bulk Access', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/bulk-access');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-bulk-access').should('be.visible');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-bulk-access> for accessibility issues
testA11y('ds-bulk-access', {
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 },
// Card titles fail this test currently
'heading-order': { enabled: false },
},
} as Options);
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Create Eperson', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/epeople/create');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Form must first be visible
cy.get('ds-eperson-form').should('be.visible');
// Analyze <ds-eperson-form> for accessibility issues
testA11y('ds-eperson-form');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Create Group', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/groups/create');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Form must first be visible
cy.get('ds-group-form').should('be.visible');
// Analyze <ds-group-form> for accessibility issues
testA11y('ds-group-form');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Edit Eperson', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/epeople/'.concat(Cypress.env('DSPACE_TEST_ADMIN_USER_UUID')).concat('/edit'));
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Form must first be visible
cy.get('ds-eperson-form').should('be.visible');
// Analyze <ds-eperson-form> for accessibility issues
testA11y('ds-eperson-form');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Edit Group', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/groups/'.concat(Cypress.env('DSPACE_ADMINISTRATOR_GROUP')).concat('/edit'));
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Form must first be visible
cy.get('ds-group-form').should('be.visible');
// Analyze <ds-group-form> for accessibility issues
testA11y('ds-group-form');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('End User Agreement', () => {
it('should pass accessibility tests', () => {
cy.visit('/info/end-user-agreement');
// Page must first be visible
cy.get('ds-end-user-agreement').should('be.visible');
// Analyze <ds-end-user-agreement> for accessibility
testA11y('ds-end-user-agreement');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Epeople registry', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/epeople');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Epeople registry page must first be visible
cy.get('ds-epeople-registry').should('be.visible');
// Analyze <ds-epeople-registry> for accessibility issues
testA11y('ds-epeople-registry');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Feedback', () => {
it('should pass accessibility tests', () => {
cy.visit('/info/feedback');
// Page must first be visible
cy.get('ds-feedback').should('be.visible');
// Analyze <ds-feedback> for accessibility
testA11y('ds-feedback');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Groups registry', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/groups');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Epeople registry page must first be visible
cy.get('ds-groups-registry').should('be.visible');
// Analyze <ds-groups-registry> for accessibility issues
testA11y('ds-groups-registry');
});
});

View File

@@ -10,4 +10,29 @@ describe('Header', () => {
// Analyze <ds-header> for accessibility
testA11y('ds-header');
});
it('should allow for changing language to German (for example)', () => {
cy.visit('/');
// Click the language switcher (globe) in header
cy.get('a[data-test="lang-switch"]').click();
// Click on the "Deusch" language in dropdown
cy.get('#language-menu-list li').contains('Deutsch').click();
// HTML "lang" attribute should switch to "de"
cy.get('html').invoke('attr', 'lang').should('eq', 'de');
// Login menu should now be in German
cy.get('a[data-test="login-menu"]').contains('Anmelden');
// Change back to English from language switcher
cy.get('a[data-test="lang-switch"]').click();
cy.get('#language-menu-list li').contains('English').click();
// HTML "lang" attribute should switch to "en"
cy.get('html').invoke('attr', 'lang').should('eq', 'en');
// Login menu should now be in English
cy.get('a[data-test="login-menu"]').contains('Log In');
});
});

View File

@@ -0,0 +1,62 @@
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/health');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
describe('Health Page > Status Tab', () => {
it('should pass accessibility tests', () => {
cy.intercept('GET', '/server/actuator/health').as('status');
cy.wait('@status');
cy.get('a[data-test="health-page.status-tab"]').click();
// Page must first be visible
cy.get('ds-health-page').should('be.visible');
cy.get('ds-health-panel').should('be.visible');
// wait for all the ds-health-info-component components to be rendered
cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => {
cy.wrap($panel).find('ds-health-component').should('be.visible');
});
// Analyze <ds-health-page> for accessibility issues
testA11y('ds-health-page', {
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);
});
});
describe('Health Page > Info Tab', () => {
it('should pass accessibility tests', () => {
cy.intercept('GET', '/server/actuator/info').as('info');
cy.wait('@info');
cy.get('a[data-test="health-page.info-tab"]').click();
// Page must first be visible
cy.get('ds-health-page').should('be.visible');
cy.get('ds-health-info').should('be.visible');
// wait for all the ds-health-info-component components to be rendered
cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => {
cy.wrap($panel).find('ds-health-info-component').should('be.visible');
});
// Analyze <ds-health-info> for accessibility issues
testA11y('ds-health-info', {
rules: {
// All panels are accordions & 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);
});
});

View File

@@ -13,8 +13,13 @@ beforeEach(() => {
describe('Edit Item > Edit Metadata tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="metadata"]').should('be.visible');
cy.get('a[data-test="metadata"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="metadata"]').should('be.visible');
cy.get('a[data-test="metadata"]').should('have.class', 'active');
// <ds-edit-item-page> tag must be loaded
cy.get('ds-edit-item-page').should('be.visible');
@@ -31,8 +36,13 @@ describe('Edit Item > Edit Metadata tab', () => {
describe('Edit Item > Status tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="status"]').should('be.visible');
cy.get('a[data-test="status"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="status"]').should('be.visible');
cy.get('a[data-test="status"]').should('have.class', 'active');
// <ds-item-status> tag must be loaded
cy.get('ds-item-status').should('be.visible');
@@ -44,8 +54,13 @@ describe('Edit Item > Status tab', () => {
describe('Edit Item > Bitstreams tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="bitstreams"]').should('be.visible');
cy.get('a[data-test="bitstreams"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="bitstreams"]').should('be.visible');
cy.get('a[data-test="bitstreams"]').should('have.class', 'active');
// <ds-item-bitstreams> tag must be loaded
cy.get('ds-item-bitstreams').should('be.visible');
@@ -68,8 +83,13 @@ describe('Edit Item > Bitstreams tab', () => {
describe('Edit Item > Curate tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="curate"]').should('be.visible');
cy.get('a[data-test="curate"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="curate"]').should('be.visible');
cy.get('a[data-test="curate"]').should('have.class', 'active');
// <ds-item-curate> tag must be loaded
cy.get('ds-item-curate').should('be.visible');
@@ -81,8 +101,13 @@ describe('Edit Item > Curate tab', () => {
describe('Edit Item > Relationships tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="relationships"]').should('be.visible');
cy.get('a[data-test="relationships"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="relationships"]').should('be.visible');
cy.get('a[data-test="relationships"]').should('have.class', 'active');
// <ds-item-relationships> tag must be loaded
cy.get('ds-item-relationships').should('be.visible');
@@ -94,8 +119,13 @@ describe('Edit Item > Relationships tab', () => {
describe('Edit Item > Version History tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="versionhistory"]').should('be.visible');
cy.get('a[data-test="versionhistory"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="versionhistory"]').should('be.visible');
cy.get('a[data-test="versionhistory"]').should('have.class', 'active');
// <ds-item-version-history> tag must be loaded
cy.get('ds-item-version-history').should('be.visible');
@@ -107,8 +137,13 @@ describe('Edit Item > Version History tab', () => {
describe('Edit Item > Access Control tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="access-control"]').should('be.visible');
cy.get('a[data-test="access-control"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="access-control"]').should('be.visible');
cy.get('a[data-test="access-control"]').should('have.class', 'active');
// <ds-item-access-control> tag must be loaded
cy.get('ds-item-access-control').should('be.visible');
@@ -120,8 +155,13 @@ describe('Edit Item > Access Control tab', () => {
describe('Edit Item > Collection Mapper tab', () => {
it('should pass accessibility tests', () => {
cy.get('a[data-test="mapper"]').should('be.visible');
cy.get('a[data-test="mapper"]').click();
// Our selected tab should be both visible & active
cy.get('a[data-test="mapper"]').should('be.visible');
cy.get('a[data-test="mapper"]').should('have.class', 'active');
// <ds-item-collection-mapper> tag must be loaded
cy.get('ds-item-collection-mapper').should('be.visible');

View File

@@ -3,31 +3,31 @@ import { testA11y } from 'cypress/support/utils';
const page = {
openLoginMenu() {
// Click the "Log In" dropdown menu in header
cy.get('ds-header [data-test="login-menu"]').click();
cy.get('[data-test="login-menu"]').click();
},
openUserMenu() {
// Once logged in, click the User menu in header
cy.get('ds-header [data-test="user-menu"]').click();
cy.get('[data-test="user-menu"]').click();
},
submitLoginAndPasswordByPressingButton(email, password) {
// Enter email
cy.get('ds-header [data-test="email"]').type(email);
cy.get('[data-test="email"]').type(email);
// Enter password
cy.get('ds-header [data-test="password"]').type(password);
cy.get('[data-test="password"]').type(password);
// Click login button
cy.get('ds-header [data-test="login-button"]').click();
cy.get('[data-test="login-button"]').click();
},
submitLoginAndPasswordByPressingEnter(email, password) {
// In opened Login modal, fill out email & password, then click Enter
cy.get('ds-header [data-test="email"]').type(email);
cy.get('ds-header [data-test="password"]').type(password);
cy.get('ds-header [data-test="password"]').type('{enter}');
cy.get('[data-test="email"]').type(email);
cy.get('[data-test="password"]').type(password);
cy.get('[data-test="password"]').type('{enter}');
},
submitLogoutByPressingButton() {
// This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button
cy.get('ds-header [data-test="logout-button"]').click();
cy.get('[data-test="logout-button"]').click();
// Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes)
cy.wait('@logout');
@@ -67,7 +67,7 @@ describe('Login Modal', () => {
// Login, and the <ds-log-in> tag should no longer exist
page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
cy.get('.form-login').should('not.exist');
cy.get('ds-log-in').should('not.exist');
// Verify we are still on homepage
cy.url().should('include', '/home');

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Metadata Import Page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/metadata-import');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Metadata import form must first be visible
cy.get('ds-metadata-import-page').should('be.visible');
// Analyze <ds-metadata-import-page> for accessibility issues
testA11y('ds-metadata-import-page');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Metadata Registry', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/registries/metadata');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-metadata-registry').should('be.visible');
// Analyze <ds-metadata-registry> for accessibility issues
testA11y('ds-metadata-registry');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Metadata Schema', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/registries/metadata/dc');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-metadata-schema').should('be.visible');
// Analyze <ds-metadata-schema> for accessibility issues
testA11y('ds-metadata-schema');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('New Process', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/processes/new');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Process form must first be visible
cy.get('ds-new-process').should('be.visible');
// Analyze <ds-new-process> for accessibility issues
testA11y('ds-new-process');
});
});

13
cypress/e2e/privacy.cy.ts Normal file
View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Privacy', () => {
it('should pass accessibility tests', () => {
cy.visit('/info/privacy');
// Page must first be visible
cy.get('ds-privacy').should('be.visible');
// Analyze <ds-privacy> for accessibility
testA11y('ds-privacy');
});
});

View File

@@ -0,0 +1,17 @@
import { testA11y } from 'cypress/support/utils';
describe('Processes Overview', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/processes');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Process overview must first be visible
cy.get('ds-process-overview').should('be.visible');
// Analyze <ds-process-overview> for accessibility issues
testA11y('ds-process-overview');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Profile page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/profile');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Process form must first be visible
cy.get('ds-profile-page').should('be.visible');
// Analyze <ds-profile-page> for accessibility issues
testA11y('ds-profile-page');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('Quality Assurance Source Page', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/notifications/quality-assurance');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Source page must first be visible
cy.get('ds-quality-assurance-source-page-component').should('be.visible');
// Analyze <ds-quality-assurance-source-page-component> for accessibility issues
testA11y('ds-quality-assurance-source-page-component');
});
});

View File

@@ -0,0 +1,16 @@
import { testA11y } from 'cypress/support/utils';
describe('System Wide Alert', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/admin/system-wide-alert');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-system-wide-alert-form').should('be.visible');
// Analyze <ds-system-wide-alert-form> for accessibility issues
testA11y('ds-system-wide-alert-form');
});
});

View File

@@ -101,11 +101,11 @@ Cypress.Commands.add('login', login);
*/
function loginViaForm(email: string, password: string): void {
// Enter email
cy.get('ds-log-in [data-test="email"]').type(email);
cy.get('[data-test="email"]').type(email);
// Enter password
cy.get('ds-log-in [data-test="password"]').type(password);
cy.get('[data-test="password"]').type(password);
// Click login button
cy.get('ds-log-in [data-test="login-button"]').click();
cy.get('[data-test="login-button"]').click();
}
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
Cypress.Commands.add('loginViaForm', loginViaForm);

View File

@@ -54,9 +54,9 @@ before(() => {
// Runs once before the first test in each "block"
beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie
// Pre-agree to all Orejime cookies by setting the orejime-anonymous cookie
// This just ensures it doesn't get in the way of matching other objects in the page.
cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}');
cy.setCookie('orejime-anonymous', '{"authentication":true,"preferences":true,"acknowledgement":true,"google-analytics":true}');
// Remove any CSRF cookies saved from prior tests
cy.clearCookie(DSPACE_XSRF_COOKIE);

View File

@@ -21,7 +21,7 @@ networks:
external: true
services:
dspace-cli:
image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}"
container_name: dspace-cli
environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.

View File

@@ -14,7 +14,7 @@
# # Therefore, it should be kept in sync with that file
services:
dspacedb:
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql"
environment:
# This LOADSQL should be kept in sync with the URL in DSpace/DSpace
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data

View File

@@ -33,7 +33,7 @@ services:
# This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
solr__D__statistics__P__autoCommit: 'false'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on:
- dspacedb
networks:
@@ -60,7 +60,7 @@ services:
# NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data
dspacedb:
container_name: dspacedb
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql"
environment:
# This LOADSQL should be kept in sync with the LOADSQL in
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
@@ -81,7 +81,7 @@ services:
# DSpace Solr container
dspacesolr:
container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
networks:
- dspacenet
ports:

View File

@@ -26,7 +26,7 @@ services:
DSPACE_REST_HOST: sandbox.dspace.org
DSPACE_REST_PORT: 443
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-latest}-dist"
build:
context: ..
dockerfile: Dockerfile.dist

View File

@@ -40,7 +40,7 @@ services:
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on:
- dspacedb
networks:
@@ -68,7 +68,7 @@ services:
dspacedb:
container_name: dspacedb
# Uses a custom Postgres image with pgcrypto installed
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
environment:
PGDATA: /pgdata
POSTGRES_PASSWORD: dspace
@@ -85,7 +85,7 @@ services:
# DSpace Solr container
dspacesolr:
container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
networks:
- dspacenet
ports:

View File

@@ -23,7 +23,7 @@ services:
DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest}
image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-latest}"
build:
context: ..
dockerfile: Dockerfile

View File

@@ -2,3 +2,4 @@
_______
- [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class
- [`dspace-angular-html/no-disabled-attribute-on-button`](./rules/no-disabled-attribute-on-button.md): Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute.

View File

@@ -0,0 +1,78 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/no-disabled-attribute-on-button`
_______
Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute.
This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled.
The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.
_______
[Source code](../../../../lint/src/rules/html/no-disabled-attribute-on-button.ts)
### Examples
#### Valid code
##### should use [dsBtnDisabled] in HTML templates
```html
<button [dsBtnDisabled]="true">Submit</button>
```
##### disabled attribute is still valid on non-button elements
```html
<input disabled>
```
##### [disabled] attribute is still valid on non-button elements
```html
<input [disabled]="true">
```
##### angular dynamic attributes that use disabled are still valid
```html
<button [class.disabled]="isDisabled">Submit</button>
```
#### Invalid code &amp; automatic fixes
##### should not use disabled attribute in HTML templates
```html
<button disabled>Submit</button>
```
Will produce the following error(s):
```
Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.
```
Result of `yarn lint --fix`:
```html
<button [dsBtnDisabled]="true">Submit</button>
```
##### should not use [disabled] attribute in HTML templates
```html
<button [disabled]="true">Submit</button>
```
Will produce the following error(s):
```
Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.
```
Result of `yarn lint --fix`:
```html
<button [dsBtnDisabled]="true">Submit</button>
```

View File

@@ -10,10 +10,13 @@ import {
bundle,
RuleExports,
} from '../../util/structure';
import * as noDisabledAttributeOnButton from './no-disabled-attribute-on-button';
import * as themedComponentUsages from './themed-component-usages';
const index = [
themedComponentUsages,
noDisabledAttributeOnButton,
] as unknown as RuleExports[];
export = {

View File

@@ -0,0 +1,147 @@
import {
TmplAstBoundAttribute,
TmplAstTextAttribute,
} from '@angular-eslint/bundled-angular-compiler';
import { TemplateParserServices } from '@angular-eslint/utils';
import {
ESLintUtils,
TSESLint,
} from '@typescript-eslint/utils';
import {
DSpaceESLintRuleInfo,
NamedTests,
} from '../../util/structure';
import { getSourceCode } from '../../util/typescript';
export enum Message {
USE_DSBTN_DISABLED = 'mustUseDsBtnDisabled',
}
export const info = {
name: 'no-disabled-attribute-on-button',
meta: {
docs: {
description: `Buttons should use the \`dsBtnDisabled\` directive instead of the HTML \`disabled\` attribute.
This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled.
The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.`,
},
type: 'problem',
fixable: 'code',
schema: [],
messages: {
[Message.USE_DSBTN_DISABLED]: 'Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
const parserServices = getSourceCode(context).parserServices as TemplateParserServices;
/**
* Some dynamic angular inputs will have disabled as name because of how Angular handles this internally (e.g [class.disabled]="isDisabled")
* But these aren't actually the disabled attribute we're looking for, we can determine this by checking the details of the keySpan
*/
function isOtherAttributeDisabled(node: TmplAstBoundAttribute | TmplAstTextAttribute): boolean {
// if the details are not null, and the details are not 'disabled', then it's not the disabled attribute we're looking for
return node.keySpan?.details !== null && node.keySpan?.details !== 'disabled';
}
/**
* Replace the disabled text with [dsBtnDisabled] in the template
*/
function replaceDisabledText(text: string ): string {
const hasBrackets = text.includes('[') && text.includes(']');
const newDisabledText = hasBrackets ? 'dsBtnDisabled' : '[dsBtnDisabled]="true"';
return text.replace('disabled', newDisabledText);
}
function inputIsChildOfButton(node: any): boolean {
return (node.parent?.tagName === 'button' || node.parent?.name === 'button');
}
function reportAndFix(node: TmplAstBoundAttribute | TmplAstTextAttribute) {
if (!inputIsChildOfButton(node) || isOtherAttributeDisabled(node)) {
return;
}
const sourceSpan = node.sourceSpan;
context.report({
messageId: Message.USE_DSBTN_DISABLED,
loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan),
fix(fixer) {
const templateText = sourceSpan.start.file.content;
const disabledText = templateText.slice(sourceSpan.start.offset, sourceSpan.end.offset);
const newText = replaceDisabledText(disabledText);
return fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], newText);
},
});
}
return {
'BoundAttribute[name="disabled"]'(node: TmplAstBoundAttribute) {
reportAndFix(node);
},
'TextAttribute[name="disabled"]'(node: TmplAstTextAttribute) {
reportAndFix(node);
},
};
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'should use [dsBtnDisabled] in HTML templates',
code: `
<button [dsBtnDisabled]="true">Submit</button>
`,
},
{
name: 'disabled attribute is still valid on non-button elements',
code: `
<input disabled>
`,
},
{
name: '[disabled] attribute is still valid on non-button elements',
code: `
<input [disabled]="true">
`,
},
{
name: 'angular dynamic attributes that use disabled are still valid',
code: `
<button [class.disabled]="isDisabled">Submit</button>
`,
},
],
invalid: [
{
name: 'should not use disabled attribute in HTML templates',
code: `
<button disabled>Submit</button>
`,
errors: [{ messageId: Message.USE_DSBTN_DISABLED }],
output: `
<button [dsBtnDisabled]="true">Submit</button>
`,
},
{
name: 'should not use [disabled] attribute in HTML templates',
code: `
<button [disabled]="true">Submit</button>
`,
errors: [{ messageId: Message.USE_DSBTN_DISABLED }],
output: `
<button [dsBtnDisabled]="true">Submit</button>
`,
},
],
} as NamedTests;
export default rule;

5912
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@
},
"@ngtools/webpack": {
"@angular/compiler-cli": "^17.3.11",
"typescript": "~5.3.3"
"typescript": "~5.4.5"
},
"@nicky-lenaers/ngx-scroll-to": {
"@angular/common": "^17.3.11",
@@ -96,19 +96,19 @@
}
},
"dependencies": {
"@angular/animations": "^17.3.11",
"@angular/animations": "^17.3.12",
"@angular/cdk": "^17.3.10",
"@angular/common": "^17.3.11",
"@angular/compiler": "^17.3.11",
"@angular/core": "^17.3.11",
"@angular/forms": "^17.3.11",
"@angular/localize": "^17.3.11",
"@angular/platform-browser": "^17.3.11",
"@angular/platform-browser-dynamic": "^17.3.11",
"@angular/platform-server": "^17.3.11",
"@angular/router": "^17.3.11",
"@angular/ssr": "^17.3.8",
"@babel/runtime": "7.21.0",
"@angular/common": "^17.3.12",
"@angular/compiler": "^17.3.12",
"@angular/core": "^17.3.12",
"@angular/forms": "^17.3.12",
"@angular/localize": "^17.3.12",
"@angular/platform-browser": "^17.3.12",
"@angular/platform-browser-dynamic": "^17.3.12",
"@angular/platform-server": "^17.3.12",
"@angular/router": "^17.3.12",
"@angular/ssr": "^17.3.11",
"@babel/runtime": "7.26.7",
"@kolkov/ngx-gallery": "^2.0.1",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^16.0.0",
@@ -118,134 +118,124 @@
"@ngrx/store": "^17.1.1",
"@ngx-translate/core": "^14.0.0",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
"axios": "^1.7.4",
"axios": "^1.7.9",
"bootstrap": "^4.6.1",
"cerialize": "0.1.18",
"cli-progress": "^3.12.0",
"colors": "^1.4.0",
"compression": "^1.7.4",
"cookie-parser": "1.4.6",
"core-js": "^3.30.1",
"compression": "^1.7.5",
"cookie-parser": "1.4.7",
"core-js": "^3.40.0",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"deepmerge": "^4.3.1",
"ejs": "^3.1.10",
"express": "^4.20.0",
"express": "^4.21.2",
"express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.1.1",
"filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5",
"http-proxy-middleware": "^2.0.7",
"http-terminator": "^3.2.0",
"isbot": "^5.1.17",
"isbot": "^5.1.22",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.2.3",
"jsonschema": "1.4.1",
"jsonschema": "1.5.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1",
"mirador": "^3.3.0",
"mirador": "^3.4.3",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
"mirador-share-plugin": "^0.16.0",
"morgan": "^1.10.0",
"ng-mocks": "^14.10.0",
"ng2-file-upload": "5.0.0",
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^16.0.0",
"ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1",
"pem": "1.14.7",
"reflect-metadata": "^0.1.13",
"orejime": "^2.3.1",
"pem": "1.14.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"sanitize-html": "^2.12.1",
"sortablejs": "1.15.0",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "~0.14.4"
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-builders/custom-webpack": "~17.0.2",
"@angular-devkit/build-angular": "^17.3.8",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/bundled-angular-compiler": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "^17.3.8",
"@angular-devkit/build-angular": "^17.3.11",
"@angular-eslint/builder": "^17.5.3",
"@angular-eslint/bundled-angular-compiler": "^17.5.3",
"@angular-eslint/eslint-plugin": "^17.5.3",
"@angular-eslint/eslint-plugin-template": "^17.5.3",
"@angular-eslint/schematics": "^17.5.3",
"@angular-eslint/template-parser": "^17.5.3",
"@angular-eslint/utils": "^17.5.3",
"@angular/cli": "^17.3.11",
"@angular/compiler-cli": "^17.3.11",
"@angular/language-service": "^17.3.11",
"@angular/language-service": "^17.3.12",
"@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.4.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@ngrx/store-devtools": "^17.1.1",
"@ngtools/webpack": "^16.2.12",
"@types/deep-freeze": "0.1.2",
"@ngtools/webpack": "^16.2.16",
"@types/deep-freeze": "0.1.5",
"@types/ejs": "^3.1.2",
"@types/express": "^4.17.17",
"@types/grecaptcha": "^3.0.9",
"@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.194",
"@types/lodash": "^4.17.15",
"@types/node": "^14.14.9",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/rule-tester": "^7.2.0",
"@typescript-eslint/utils": "^7.2.0",
"axe-core": "^4.7.2",
"browser-sync": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/rule-tester": "^7.18.0",
"@typescript-eslint/utils": "^7.18.0",
"axe-core": "^4.10.2",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
"cypress": "12.17.4",
"cypress-axe": "^1.4.0",
"cypress": "^13.17.0",
"cypress-axe": "^1.6.0",
"deep-freeze": "0.0.1",
"eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.4.1",
"eslint-plugin-dspace-angular-html": "file:./lint/dist/src/rules/html",
"eslint-plugin-dspace-angular-ts": "file:./lint/dist/src/rules/ts",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-jsonc": "^2.19.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^3.2.0",
"express-static-gzip": "^2.1.7",
"express-static-gzip": "^2.2.0",
"jasmine": "^3.8.0",
"jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
"karma": "^6.4.2",
"karma": "^6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5",
"ng-mocks": "^14.13.2",
"ngx-mask": "14.2.4",
"nodemon": "^2.0.22",
"postcss": "^8.4",
"postcss-apply": "0.12.0",
"postcss": "^8.5",
"postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2",
"postcss-responsive-type": "1.0.0",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"rimraf": "^3.0.2",
"rxjs-spy": "^8.0.2",
"sass": "~1.62.0",
"sass": "~1.84.0",
"sass-loader": "^12.6.0",
"sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2",
"typescript": "~5.3.3",
"webpack": "5.94.0",
"webpack-bundle-analyzer": "^4.8.0",
"typescript": "~5.4.5",
"webpack": "5.97.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}

View File

@@ -1,8 +1,6 @@
module.exports = {
plugins: [
require('postcss-import')(),
require('postcss-preset-env')(),
require('postcss-apply')(),
require('postcss-responsive-type')()
require('postcss-preset-env')()
]
};

View File

@@ -81,6 +81,9 @@ let anonymousCache: LRU<string, any>;
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
// The REST server base URL
const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl;
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
@@ -156,7 +159,7 @@ export function app() {
* Proxy the sitemaps
*/
router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`,
target: `${REST_BASE_URL}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true,
}));
@@ -165,7 +168,7 @@ export function app() {
* Proxy the linksets
*/
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
target: `${REST_BASE_URL}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true,
}));
@@ -218,7 +221,7 @@ export function app() {
* The callback function to serve server side angular
*/
function ngApp(req, res, next) {
if (environment.ssr.enabled) {
if (environment.ssr.enabled && req.method === 'GET' && (req.path === '/' || environment.ssr.paths.some(pathPrefix => req.path.startsWith(pathPrefix)))) {
// Render the page to user via SSR (server side rendering)
serverSideRender(req, res, next);
} else {
@@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) {
})
.then((html) => {
if (hasValue(html)) {
// Replace REST URL with UI URL
if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
}
// save server side rendered page to cache (if any are enabled)
saveToCache(req, html);
if (sendToUser) {
@@ -623,7 +631,7 @@ function start() {
* The callback function to serve health check requests
*/
function healthCheck(req, res) {
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`;
const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
axios.get(baseUrl)
.then((response) => {
res.status(response.status).send(response.data);

View File

@@ -10,7 +10,7 @@
<button class="btn btn-outline-primary mr-3" (click)="reset()">
{{ 'access-control-cancel' | translate }}
</button>
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
<button class="btn btn-primary" [dsBtnDisabled]="!canExport()" (click)="submit()">
{{ 'access-control-execute' | translate }}
</button>
</div>

View File

@@ -14,6 +14,7 @@ import {
} from 'rxjs/operators';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component';
@@ -27,6 +28,7 @@ import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.com
TranslateModule,
BulkAccessSettingsComponent,
BulkAccessBrowseComponent,
BtnDisabledDirective,
],
standalone: true,
})

View File

@@ -61,7 +61,7 @@
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
[ngClass]="{'table-primary' : (activeEPerson$ | async) === epersonDto.eperson}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>

View File

@@ -42,6 +42,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
@@ -151,7 +152,7 @@ describe('EPeopleRegistryComponent', () => {
paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]),
TranslateModule.forRoot(), EPeopleRegistryComponent],
TranslateModule.forRoot(), EPeopleRegistryComponent, BtnDisabledDirective],
providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },

View File

@@ -100,6 +100,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/
ePeopleDto$: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>({} as any);
activeEPerson$: Observable<EPerson>;
/**
* An observable for the pageInfo, needed to pass to the pagination component
*/
@@ -165,6 +167,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
initialisePage() {
this.searching$.next(true);
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
this.activeEPerson$ = this.epersonService.getActiveEPerson();
this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => {
if (epeople.pageInfo.totalElements > 0) {
@@ -232,23 +235,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
);
}
/**
* Checks whether the given EPerson is active (being edited)
* @param eperson
*/
isActive(eperson: EPerson): Observable<boolean> {
return this.getActiveEPerson().pipe(
map((activeEPerson) => eperson === activeEPerson),
);
}
/**
* Gets the active eperson (being edited)
*/
getActiveEPerson(): Observable<EPerson> {
return this.epersonService.getActiveEPerson();
}
/**
* Deletes EPerson, show notification on success/failure & updates EPeople list
*/

View File

@@ -2,7 +2,7 @@
<div class="group-form row">
<div class="col-12">
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<div *ngIf="activeEPerson$ | async; then editHeader; else createHeader"></div>
<ng-template #createHeader>
<h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
@@ -25,7 +25,7 @@
</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
<button class="btn btn-primary" [dsBtnDisabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
@@ -44,7 +44,7 @@
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading>
<div *ngIf="epersonService.getActiveEPerson() | async">
<div *ngIf="activeEPerson$ | async">
<h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
<ds-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-loading>
@@ -75,7 +75,9 @@
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
<td class="align-middle">
{{ dsoNameService.getName((group.object | async)?.payload) }}
</td>
</tr>
</tbody>
</table>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
@@ -19,12 +19,10 @@ import {
import {
ActivatedRoute,
Router,
RouterModule,
} from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
@@ -45,11 +43,11 @@ import { GroupDataService } from '../../../core/eperson/group-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PageInfo } from '../../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
import { FormComponent } from '../../../shared/form/form.component';
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
@@ -92,9 +90,6 @@ describe('EPersonFormComponent', () => {
ePersonDataServiceStub = {
activeEPerson: null,
allEpeople: mockEPeople,
getEPeople(): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList(null, this.allEpeople));
},
getActiveEPerson(): Observable<EPerson> {
return observableOf(this.activeEPerson);
},
@@ -227,13 +222,9 @@ describe('EPersonFormComponent', () => {
route = new ActivatedRouteStub();
router = new RouterStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BtnDisabledDirective, BrowserModule,
RouterModule.forRoot([]),
TranslateModule.forRoot(),
EPersonFormComponent,
HasNoValuePipe,
],
@@ -251,7 +242,7 @@ describe('EPersonFormComponent', () => {
{ provide: Router, useValue: router },
EPeopleRegistryComponent,
],
schemas: [NO_ERRORS_SCHEMA],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(EPersonFormComponent, {
remove: { imports: [ ThemedLoadingComponent, PaginationComponent,FormComponent] },
@@ -274,37 +265,13 @@ describe('EPersonFormComponent', () => {
});
describe('check form validation', () => {
let firstName;
let lastName;
let email;
let canLogIn;
let requireCertificate;
let canLogIn: boolean;
let requireCertificate: boolean;
let expected;
beforeEach(() => {
firstName = 'testName';
lastName = 'testLastName';
email = 'testEmail@test.com';
canLogIn = false;
requireCertificate = false;
expected = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: firstName,
},
],
'eperson.lastname': [
{
value: lastName,
},
],
},
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit');
component.canLogIn.value = canLogIn;
component.requireCertificate.value = requireCertificate;
@@ -378,15 +345,13 @@ describe('EPersonFormComponent', () => {
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
});
});
});
describe('when submitting the form', () => {
let firstName;
let lastName;
let email;
let canLogIn;
let canLogIn: boolean;
let requireCertificate;
let expected;
@@ -415,6 +380,7 @@ describe('EPersonFormComponent', () => {
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit');
component.ngOnInit();
component.firstName.value = firstName;
component.lastName.value = lastName;
component.email.value = email;
@@ -454,9 +420,17 @@ describe('EPersonFormComponent', () => {
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
_links: undefined,
_links: {
groups: {
href: '',
},
self: {
href: '',
},
},
});
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
component.ngOnInit();
component.onSubmit();
fixture.detectChanges();
});
@@ -504,22 +478,19 @@ describe('EPersonFormComponent', () => {
});
describe('delete', () => {
let ePersonId;
let eperson: EPerson;
let modalService;
beforeEach(() => {
spyOn(authService, 'impersonate').and.callThrough();
ePersonId = 'testEPersonId';
eperson = EPersonMock;
component.epersonInitial = eperson;
component.canDelete$ = observableOf(true);
spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson));
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
component.ngOnInit();
fixture.detectChanges();
});
it('the delete button should be visible if the ePerson can be deleted', () => {
@@ -546,7 +517,8 @@ describe('EPersonFormComponent', () => {
// ePersonDataServiceStub.activeEPerson = eperson;
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(createSuccessfulRemoteDataObject$('No Content', 204));
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false);
expect(deleteButton.nativeElement.getAttribute('aria-disabled')).toBeNull();
expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse();
deleteButton.triggerEventHandler('click', null);
fixture.detectChanges();
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);

View File

@@ -65,6 +65,7 @@ import {
import { PageInfo } from '../../../core/shared/page-info.model';
import { Registration } from '../../../core/shared/registration.model';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -92,6 +93,7 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator';
PaginationComponent,
RouterLink,
HasNoValuePipe,
BtnDisabledDirective,
],
standalone: true,
})
@@ -189,6 +191,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/
canImpersonate$: Observable<boolean>;
/**
* The current {@link EPerson}
*/
activeEPerson$: Observable<EPerson>;
/**
* List of subscriptions
*/
@@ -254,7 +261,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
protected route: ActivatedRoute,
protected router: Router,
) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
}
ngOnInit() {
this.activeEPerson$ = this.epersonService.getActiveEPerson();
this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
if (hasValue(eperson)) {
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
@@ -262,9 +273,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.submitLabel = 'form.submit';
}
}));
}
ngOnInit() {
this.initialisePage();
}
@@ -272,130 +280,121 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page
*/
initialisePage() {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
this.translateService.get(`${this.messagePrefix}.email`),
this.translateService.get(`${this.messagePrefix}.canLogIn`),
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
this.translateService.get(`${this.messagePrefix}.emailHint`),
]).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
this.firstName = new DynamicInputModel({
id: 'firstName',
label: firstName,
name: 'firstName',
validators: {
required: null,
},
required: true,
});
this.lastName = new DynamicInputModel({
id: 'lastName',
label: lastName,
name: 'lastName',
validators: {
required: null,
},
required: true,
});
this.email = new DynamicInputModel({
id: 'email',
label: email,
name: 'email',
validators: {
required: null,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
},
required: true,
errorMessages: {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail',
},
hint: emailHint,
});
this.canLogIn = new DynamicCheckboxModel(
{
id: 'canLogIn',
label: canLogIn,
name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true),
});
this.requireCertificate = new DynamicCheckboxModel(
{
id: 'requireCertificate',
label: requireCertificate,
name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false),
});
this.formModel = [
this.firstName,
this.lastName,
this.email,
this.canLogIn,
this.requireCertificate,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null) {
this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, {
currentPage: 1,
elementsPerPage: this.config.pageSize,
});
}
this.formGroup.patchValue({
firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '',
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
email: eperson != null ? eperson.email : '',
canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false,
});
if (eperson === null && !!this.formGroup.controls.email) {
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
if (this.route.snapshot.params.id) {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
const activeEPerson$ = this.epersonService.getActiveEPerson();
this.groups$ = activeEPerson$.pipe(
switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1,
elementsPerPage: this.config.pageSize,
})]);
}),
switchMap(([eperson, findListOptions]) => {
if (eperson != null) {
return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
}
return observableOf(undefined);
}),
);
this.groupsPageInfoState$ = this.groups$.pipe(
map(groupsRD => groupsRD.payload.pageInfo),
);
this.canImpersonate$ = activeEPerson$.pipe(
switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
} else {
return observableOf(false);
}
}),
);
this.canDelete$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)),
);
this.canReset$ = observableOf(true);
}
this.firstName = new DynamicInputModel({
id: 'firstName',
label: this.translateService.instant(`${this.messagePrefix}.firstName`),
name: 'firstName',
validators: {
required: null,
},
required: true,
});
this.lastName = new DynamicInputModel({
id: 'lastName',
label: this.translateService.instant(`${this.messagePrefix}.lastName`),
name: 'lastName',
validators: {
required: null,
},
required: true,
});
this.email = new DynamicInputModel({
id: 'email',
label: this.translateService.instant(`${this.messagePrefix}.email`),
name: 'email',
validators: {
required: null,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
},
required: true,
errorMessages: {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail',
},
hint: this.translateService.instant(`${this.messagePrefix}.emailHint`),
});
this.canLogIn = new DynamicCheckboxModel(
{
id: 'canLogIn',
label: this.translateService.instant(`${this.messagePrefix}.canLogIn`),
name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true),
});
this.requireCertificate = new DynamicCheckboxModel(
{
id: 'requireCertificate',
label: this.translateService.instant(`${this.messagePrefix}.requireCertificate`),
name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false),
});
this.formModel = [
this.firstName,
this.lastName,
this.email,
this.canLogIn,
this.requireCertificate,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => {
if (eperson != null) {
this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, {
currentPage: 1,
elementsPerPage: this.config.pageSize,
}, undefined, undefined, followLink('object'));
}
this.formGroup.patchValue({
firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '',
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
email: eperson != null ? eperson.email : '',
canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false,
});
if (eperson === null && !!this.formGroup.controls.email) {
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
}));
this.groups$ = this.activeEPerson$.pipe(
switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1,
elementsPerPage: this.config.pageSize,
})]);
}),
switchMap(([eperson, findListOptions]) => {
if (eperson != null) {
return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
}
return observableOf(undefined);
}),
);
this.groupsPageInfoState$ = this.groups$.pipe(
map(groupsRD => groupsRD.payload.pageInfo),
);
this.canImpersonate$ = this.activeEPerson$.pipe(
switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
} else {
return observableOf(false);
}
}),
);
this.canDelete$ = this.activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)),
);
this.canReset$ = observableOf(true);
}
/**
@@ -414,7 +413,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Emit the updated/created eperson using the EventEmitter submitForm
*/
onSubmit() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe(
this.activeEPerson$.pipe(take(1)).subscribe(
(ePerson: EPerson) => {
const values = {
metadata: {
@@ -533,7 +532,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete(): void {
this.epersonService.getActiveEPerson().pipe(
this.activeEPerson$.pipe(
take(1),
switchMap((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
@@ -637,7 +636,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Update the list of groups by fetching it from the rest api or cache
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => {
this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options);
}));
}

View File

@@ -2,7 +2,7 @@
<div class="group-form row">
<div class="col-12">
<div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div>
<div *ngIf="activeGroup$ | async; then editHeader; else createHeader"></div>
<ng-template #createHeader>
<h1 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h1>
@@ -23,11 +23,15 @@
</h1>
</ng-template>
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
<ds-alert *ngIf="(canEdit$ | async) !== true && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName((getLinkedDSO(groupBeingEdited) | async)?.payload), comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })">
</ds-alert>
<ng-container *ngIf="(activeGroup$ | async) as groupBeingEdited">
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertType.Warning"
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
<ng-container *ngIf="(activeGroupLinkedDSO$ | async) as activeGroupLinkedDSO">
<ds-alert *ngIf="(canEdit$ | async) !== true" [type]="AlertType.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName(activeGroupLinkedDSO), comcol: activeGroupLinkedDSO.type, comcolEditRolesRoute: (linkedEditRolesRoute$ | async) })">
</ds-alert>
</ng-container>
</ng-container>
<ds-form [formId]="formId"
[formModel]="formModel"
@@ -39,22 +43,21 @@
<button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div>
<div after *ngIf="(canEdit$ | async) && !groupBeingEdited?.permanent" class="btn-group">
<div after *ngIf="(canEdit$ | async) && !(activeGroup$ | async)?.permanent" class="btn-group">
<button (click)="delete()" class="btn btn-danger delete-button" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button>
</div>
</ds-form>
<div class="mb-5">
<ds-members-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
</div>
<ds-subgroups-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
<ng-container *ngIf="(activeGroup$ | async) as groupBeingEdited">
<div class="mb-5">
<ds-members-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
</div>
<ds-subgroups-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
</ng-container>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
@@ -23,11 +23,7 @@ import {
} from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { Operation } from 'fast-json-patch';
import {
Observable,
@@ -61,15 +57,14 @@ import { FormComponent } from '../../../shared/form/form.component';
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { RouterMock } from '../../../shared/mocks/router.mock';
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
import {
GroupMock,
GroupMock2,
} from '../../../shared/testing/group-mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock';
import { GroupFormComponent } from './group-form.component';
import { MembersListComponent } from './members-list/members-list.component';
import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component';
@@ -78,19 +73,19 @@ import { ValidateGroupExists } from './validators/group-exists.validator';
describe('GroupFormComponent', () => {
let component: GroupFormComponent;
let fixture: ComponentFixture<GroupFormComponent>;
let translateService: TranslateService;
let builderService: FormBuilderService;
let ePersonDataServiceStub: any;
let groupsDataServiceStub: any;
let dsoDataServiceStub: any;
let authorizationService: AuthorizationDataService;
let notificationService: NotificationsServiceStub;
let router;
let router: RouterMock;
let route: ActivatedRouteStub;
let groups;
let groupName;
let groupDescription;
let expected;
let groups: Group[];
let groupName: string;
let groupDescription: string;
let expected: Group;
beforeEach(waitForAsync(() => {
groups = [GroupMock, GroupMock2];
@@ -105,6 +100,15 @@ describe('GroupFormComponent', () => {
},
],
},
object: createSuccessfulRemoteDataObject$(undefined),
_links: {
self: {
href: 'group-selflink',
},
object: {
href: 'group-objectlink',
},
},
});
ePersonDataServiceStub = {};
groupsDataServiceStub = {
@@ -141,7 +145,14 @@ describe('GroupFormComponent', () => {
create(group: Group): Observable<RemoteData<Group>> {
this.allGroups = [...this.allGroups, group];
this.createdGroup = Object.assign({}, group, {
_links: { self: { href: 'group-selflink' } },
_links: {
self: {
href: 'group-selflink',
},
object: {
href: 'group-objectlink',
},
},
});
return createSuccessfulRemoteDataObject$(this.createdGroup);
},
@@ -223,17 +234,15 @@ describe('GroupFormComponent', () => {
return typeof value === 'object' && value !== null;
},
});
translateService = getMockTranslateService();
router = new RouterMock();
route = new ActivatedRouteStub();
notificationService = new NotificationsServiceStub();
return TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}), GroupFormComponent],
TranslateModule.forRoot(),
GroupFormComponent,
],
providers: [
{ provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
@@ -249,14 +258,11 @@ describe('GroupFormComponent', () => {
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{
provide: ActivatedRoute,
useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) },
},
{ provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: router },
{ provide: AuthorizationDataService, useValue: authorizationService },
],
schemas: [NO_ERRORS_SCHEMA],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(GroupFormComponent, {
remove: { imports: [
@@ -279,8 +285,8 @@ describe('GroupFormComponent', () => {
describe('when submitting the form', () => {
beforeEach(() => {
spyOn(component.submitForm, 'emit');
component.groupName.value = groupName;
component.groupDescription.value = groupDescription;
component.groupName.setValue(groupName);
component.groupDescription.setValue(groupDescription);
});
describe('without active Group', () => {
beforeEach(() => {
@@ -288,14 +294,22 @@ describe('GroupFormComponent', () => {
fixture.detectChanges();
});
it('should emit a new group using the correct values', (async () => {
await fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
});
it('should emit a new group using the correct values', (() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(jasmine.objectContaining({
name: groupName,
metadata: {
'dc.description': [
{
value: groupDescription,
},
],
},
}));
}));
});
describe('with active Group', () => {
let expected2;
let expected2: Group;
beforeEach(() => {
expected2 = Object.assign(new Group(), {
name: 'newGroupName',
@@ -306,15 +320,24 @@ describe('GroupFormComponent', () => {
},
],
},
object: createSuccessfulRemoteDataObject$(undefined),
_links: {
self: {
href: 'group-selflink',
},
object: {
href: 'group-objectlink',
},
},
});
spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf(expected));
spyOn(groupsDataServiceStub, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(expected2));
component.groupName.value = 'newGroupName';
component.onSubmit();
fixture.detectChanges();
component.ngOnInit();
});
it('should edit with name and description operations', () => {
component.groupName.setValue('newGroupName');
component.onSubmit();
const operations = [{
op: 'add',
path: '/metadata/dc.description',
@@ -328,9 +351,8 @@ describe('GroupFormComponent', () => {
});
it('should edit with description operations', () => {
component.groupName.value = null;
component.groupName.setValue(null);
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'add',
path: '/metadata/dc.description',
@@ -340,9 +362,9 @@ describe('GroupFormComponent', () => {
});
it('should edit with name operations', () => {
component.groupDescription.value = null;
component.groupName.setValue('newGroupName');
component.groupDescription.setValue(null);
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'replace',
path: '/name',
@@ -351,12 +373,13 @@ describe('GroupFormComponent', () => {
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should emit the existing group using the correct new values', (async () => {
await fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
});
}));
it('should emit the existing group using the correct new values', () => {
component.onSubmit();
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
});
it('should emit success notification', () => {
component.onSubmit();
expect(notificationService.success).toHaveBeenCalled();
});
});
@@ -371,11 +394,8 @@ describe('GroupFormComponent', () => {
describe('check form validation', () => {
let groupCommunity;
beforeEach(() => {
groupName = 'testName';
groupCommunity = 'testgroupCommunity';
groupDescription = 'testgroupDescription';
expected = Object.assign(new Group(), {
@@ -387,8 +407,17 @@ describe('GroupFormComponent', () => {
},
],
},
_links: {
self: {
href: 'group-selflink',
},
object: {
href: 'group-objectlink',
},
},
});
spyOn(component.submitForm, 'emit');
spyOn(dsoDataServiceStub, 'findByHref').and.returnValue(observableOf(expected));
fixture.detectChanges();
component.initialisePage();
@@ -438,21 +467,20 @@ describe('GroupFormComponent', () => {
});
describe('delete', () => {
let deleteButton;
let deleteButton: HTMLButtonElement;
beforeEach(() => {
component.initialisePage();
component.canEdit$ = observableOf(true);
component.groupBeingEdited = {
beforeEach(async () => {
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
component.activeGroup$ = observableOf({
id: 'active-group',
permanent: false,
} as Group;
} as Group);
component.canEdit$ = observableOf(true);
component.initialisePage();
fixture.detectChanges();
deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement;
spyOn(groupsDataServiceStub, 'delete').and.callThrough();
spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' }));
});
describe('if confirmed via modal', () => {

View File

@@ -11,7 +11,10 @@ import {
OnInit,
Output,
} from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';
import {
AbstractControl,
UntypedFormGroup,
} from '@angular/forms';
import {
ActivatedRoute,
Router,
@@ -31,13 +34,10 @@ import { Operation } from 'fast-json-patch';
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription,
} from 'rxjs';
import {
catchError,
debounceTime,
filter,
map,
switchMap,
take,
@@ -53,7 +53,6 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { Group } from '../../../core/eperson/models/group.model';
import { Collection } from '../../../core/shared/collection.model';
@@ -61,9 +60,9 @@ import { Community } from '../../../core/shared/community.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import {
getAllCompletedRemoteData,
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
} from '../../../core/shared/operators';
import { AlertComponent } from '../../../shared/alert/alert.component';
@@ -117,9 +116,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
/**
* Dynamic models for the inputs of form
*/
groupName: DynamicInputModel;
groupCommunity: DynamicInputModel;
groupDescription: DynamicTextAreaModel;
groupName: AbstractControl;
groupCommunity: AbstractControl;
groupDescription: AbstractControl;
/**
* A list of all dynamic input models
@@ -162,21 +161,30 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/
subs: Subscription[] = [];
/**
* Group currently being edited
*/
groupBeingEdited: Group;
/**
* Observable whether or not the logged in user is allowed to delete the Group & doesn't have a linked object (community / collection linked to workspace group
*/
canEdit$: Observable<boolean>;
/**
* The AlertType enumeration
* @type {AlertType}
* The current {@link Group}
*/
public AlertTypeEnum = AlertType;
activeGroup$: Observable<Group>;
/**
* The current {@link Group}'s linked {@link Community}/{@link Collection}
*/
activeGroupLinkedDSO$: Observable<DSpaceObject>;
/**
* Link to the current {@link Group}'s {@link Community}/{@link Collection} edit role tab
*/
linkedEditRolesRoute$: Observable<string>;
/**
* The AlertType enumeration
*/
public readonly AlertType = AlertType;
/**
* Subscription to email field value change
@@ -186,126 +194,121 @@ export class GroupFormComponent implements OnInit, OnDestroy {
constructor(
public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private route: ActivatedRoute,
protected dSpaceObjectDataService: DSpaceObjectDataService,
protected formBuilderService: FormBuilderService,
protected translateService: TranslateService,
protected notificationsService: NotificationsService,
protected route: ActivatedRoute,
protected router: Router,
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
protected authorizationService: AuthorizationDataService,
protected modalService: NgbModal,
public requestService: RequestService,
protected changeDetectorRef: ChangeDetectorRef,
public dsoNameService: DSONameService,
) {
}
ngOnInit() {
ngOnInit(): void {
if (this.route.snapshot.params.groupId !== 'newGroup') {
this.setActiveGroup(this.route.snapshot.params.groupId);
}
this.activeGroup$ = this.groupDataService.getActiveGroup();
this.activeGroupLinkedDSO$ = this.getActiveGroupLinkedDSO();
this.linkedEditRolesRoute$ = this.getLinkedEditRolesRoute();
this.canEdit$ = this.activeGroupLinkedDSO$.pipe(
switchMap((dso: DSpaceObject) => {
if (hasValue(dso)) {
return [false];
} else {
return this.activeGroup$.pipe(
hasValueOperator(),
switchMap((group: Group) => this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self)),
);
}
}),
);
this.initialisePage();
}
initialisePage() {
this.subs.push(this.route.params.subscribe((params) => {
if (params.groupId !== 'newGroup') {
this.setActiveGroup(params.groupId);
}
}));
this.canEdit$ = this.groupDataService.getActiveGroup().pipe(
hasValueOperator(),
switchMap((group: Group) => {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined),
this.hasLinkedDSO(group),
]).pipe(
map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO),
);
const groupNameModel = new DynamicInputModel({
id: 'groupName',
label: this.translateService.instant(`${this.messagePrefix}.groupName`),
name: 'groupName',
validators: {
required: null,
},
required: true,
});
const groupCommunityModel = new DynamicInputModel({
id: 'groupCommunity',
label: this.translateService.instant(`${this.messagePrefix}.groupCommunity`),
name: 'groupCommunity',
required: false,
readOnly: true,
});
const groupDescriptionModel = new DynamicTextAreaModel({
id: 'groupDescription',
label: this.translateService.instant(`${this.messagePrefix}.groupDescription`),
name: 'groupDescription',
required: false,
spellCheck: environment.form.spellCheck,
});
this.formModel = [
groupNameModel,
groupDescriptionModel,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.groupName = this.formGroup.get('groupName');
this.groupDescription = this.formGroup.get('groupDescription');
if (hasValue(this.groupName)) {
this.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService));
this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
this.subs.push(
observableCombineLatest([
this.activeGroup$,
this.canEdit$,
this.activeGroupLinkedDSO$,
]).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) {
// Disable group name exists validator
this.formGroup.controls.groupName.clearAsyncValidators();
if (isNotEmpty(linkedObject?.name)) {
if (!this.formGroup.controls.groupCommunity) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, groupCommunityModel);
this.groupDescription = this.formGroup.get('groupCommunity');
}
this.formGroup.patchValue({
groupName: activeGroup.name,
groupCommunity: linkedObject?.name ?? '',
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
} else {
this.formModel = [
groupNameModel,
groupDescriptionModel,
];
this.formGroup.patchValue({
groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
} else {
this.formGroup.enable();
}
}
}),
);
observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`),
]).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({
id: 'groupName',
label: groupName,
name: 'groupName',
validators: {
required: null,
},
required: true,
});
this.groupCommunity = new DynamicInputModel({
id: 'groupCommunity',
label: groupCommunity,
name: 'groupCommunity',
required: false,
readOnly: true,
});
this.groupDescription = new DynamicTextAreaModel({
id: 'groupDescription',
label: groupDescription,
name: 'groupDescription',
required: false,
spellCheck: environment.form.spellCheck,
});
this.formModel = [
this.groupName,
this.groupDescription,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
if (this.formGroup.controls.groupName) {
this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService));
this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
this.subs.push(
observableCombineLatest([
this.groupDataService.getActiveGroup(),
this.canEdit$,
this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))),
]).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) {
// Disable group name exists validator
this.formGroup.controls.groupName.clearAsyncValidators();
this.groupBeingEdited = activeGroup;
if (linkedObject?.name) {
if (!this.formGroup.controls.groupCommunity) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
this.formGroup.patchValue({
groupName: activeGroup.name,
groupCommunity: linkedObject?.name ?? '',
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
} else {
this.formModel = [
this.groupName,
this.groupDescription,
];
this.formGroup.patchValue({
groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
setTimeout(() => {
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
}
}, 200);
}
}),
);
});
}
/**
@@ -324,9 +327,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* Emit the updated/created eperson using the EventEmitter submitForm
*/
onSubmit() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe(
(group: Group) => {
const values = {
this.activeGroup$.pipe(take(1)).subscribe((group: Group) => {
if (group === null) {
this.createNewGroup({
name: this.groupName.value,
metadata: {
'dc.description': [
@@ -335,14 +338,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
},
],
},
};
if (group === null) {
this.createNewGroup(values);
} else {
this.editGroup(group);
}
},
);
});
} else {
this.editGroup(group);
}
});
}
/**
@@ -448,7 +448,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* @param groupSelfLink SelfLink of group to set as active
*/
setActiveGroupWithLink(groupSelfLink: string) {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
this.activeGroup$.pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup === null) {
this.groupDataService.cancelEditGroup();
this.groupDataService.findByHref(groupSelfLink, false, false, followLink('subgroups'), followLink('epersons'), followLink('object'))
@@ -467,7 +467,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => {
this.activeGroup$.pipe(take(1)).subscribe((group: Group) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.name = this.dsoNameService.getName(group);
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header';
@@ -511,52 +511,38 @@ export class GroupFormComponent implements OnInit, OnDestroy {
}
/**
* Check if group has a linked object (community or collection linked to a workflow group)
* @param group
* Get the active {@link Group}'s linked object if it has one ({@link Community} or {@link Collection} linked to a
* workflow group)
*/
hasLinkedDSO(group: Group): Observable<boolean> {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
return hasValue(rd) && hasValue(rd.payload);
}),
catchError(() => observableOf(false)),
);
}
getActiveGroupLinkedDSO(): Observable<DSpaceObject> {
return this.activeGroup$.pipe(
hasValueOperator(),
switchMap((group: Group) => {
if (group.object === undefined) {
return this.dSpaceObjectDataService.findByHref(group._links.object.href);
}
return group.object;
}),
getAllCompletedRemoteData(),
getRemoteDataPayload(),
);
}
/**
* Get group's linked object if it has one (community or collection linked to a workflow group)
* @param group
* Get the route to the edit roles tab of the active {@link Group}'s linked object (community or collection linked
* to a workflow group) if it has one
*/
getLinkedDSO(group: Group): Observable<RemoteData<DSpaceObject>> {
if (hasValue(group) && hasValue(group._links.object.href)) {
if (group.object === undefined) {
return this.dSpaceObjectDataService.findByHref(group._links.object.href);
}
return group.object;
}
}
/**
* Get the route to the edit roles tab of the group's linked object (community or collection linked to a workflow group) if it has one
* @param group
*/
getLinkedEditRolesRoute(group: Group): Observable<string> {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
const dso = rd.payload;
switch ((dso as any).type) {
case Community.type.value:
return getCommunityEditRolesRoute(rd.payload.id);
case Collection.type.value:
return getCollectionEditRolesRoute(rd.payload.id);
}
}
}),
);
}
getLinkedEditRolesRoute(): Observable<string> {
return this.activeGroupLinkedDSO$.pipe(
hasValueOperator(),
map((dso: DSpaceObject) => {
switch ((dso as any).type) {
case Community.type.value:
return getCommunityEditRolesRoute(dso.id);
case Collection.type.value:
return getCollectionEditRolesRoute(dso.id);
}
}),
);
}
}

View File

@@ -35,14 +35,14 @@
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(epersonDTO.eperson)"
*ngIf="epersonDTO.ableToDelete"
[disabled]="actionConfig.remove.disabled"
[dsBtnDisabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!epersonDTO.ableToDelete"
(click)="addMemberToGroup(epersonDTO.eperson)"
[disabled]="actionConfig.add.disabled"
[dsBtnDisabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
@@ -122,7 +122,7 @@
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="addMemberToGroup(eperson)"
[disabled]="actionConfig.add.disabled"
[dsBtnDisabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>

View File

@@ -54,6 +54,7 @@ import {
getFirstCompletedRemoteData,
getRemoteDataPayload,
} from '../../../../core/shared/operators';
import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive';
import { ContextHelpDirective } from '../../../../shared/context-help.directive';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
@@ -113,6 +114,7 @@ export interface EPersonListActionConfig {
RouterLink,
NgClass,
NgForOf,
BtnDisabledDirective,
],
standalone: true,
})

View File

@@ -69,7 +69,7 @@
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngSwitchCase="false"
[disabled]="true"
[dsBtnDisabled]="true"
class="btn btn-outline-primary btn-sm btn-edit"
placement="left"
[ngbTooltip]="'admin.access-control.epeople.table.edit.buttons.edit-disabled' | translate"

View File

@@ -50,6 +50,7 @@ import { RouteService } from '../../core/services/route.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import {
DSONameServiceMock,
UNDEFINED_NAME,
@@ -208,6 +209,7 @@ describe('GroupsRegistryComponent', () => {
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot(),
GroupsRegistryComponent,
BtnDisabledDirective,
],
providers: [GroupsRegistryComponent,
{ provide: DSONameService, useValue: new DSONameServiceMock() },
@@ -278,7 +280,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull();
expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse();
});
});
@@ -312,7 +315,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull();
expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse();
});
});
});
@@ -331,7 +335,8 @@ describe('GroupsRegistryComponent', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeTrue();
expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBe('true');
expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeTrue();
});
});
});

View File

@@ -62,6 +62,7 @@ import {
getRemoteDataPayload,
} from '../../core/shared/operators';
import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { hasValue } from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -84,6 +85,7 @@ import { followLink } from '../../shared/utils/follow-link-config.model';
NgSwitchCase,
NgbTooltipModule,
NgForOf,
BtnDisabledDirective,
],
standalone: true,
})

View File

@@ -9,9 +9,9 @@
<ds-pagination
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0"
*ngIf="(bitstreamFormats$ | async)?.payload?.totalElements > 0"
[paginationOptions]="pageConfig"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
[collectionSize]="(bitstreamFormats$ | async)?.payload?.totalElements"
[hideGear]="false"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
@@ -26,12 +26,12 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<tr *ngFor="let bitstreamFormat of (bitstreamFormats$ | async)?.payload?.page">
<td>
<label class="mb-0">
<input type="checkbox"
[attr.aria-label]="'admin.registries.bitstream-formats.select' | translate"
[checked]="isSelected(bitstreamFormat) | async"
[checked]="(selectedBitstreamFormatIDs$ | async)?.includes(bitstreamFormat.id)"
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
>
<span class="sr-only">{{'admin.registries.bitstream-formats.select' | translate}}&#125;</span>
@@ -46,13 +46,13 @@
</table>
</div>
</ds-pagination>
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements === 0" class="alert alert-info" role="alert">
<div *ngIf="(bitstreamFormats$ | async)?.payload?.totalElements === 0" class="alert alert-info" role="alert">
{{'admin.registries.bitstream-formats.no-items' | translate}}
</div>
<div>
<button *ngIf="(bitstreamFormats | async)?.payload?.page?.length > 0" class="btn btn-primary deselect" (click)="deselectAll()">{{'admin.registries.bitstream-formats.table.deselect-all' | translate}}</button>
<button *ngIf="(bitstreamFormats | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFormats()">{{'admin.registries.bitstream-formats.table.delete' | translate}}</button>
<button *ngIf="(bitstreamFormats$ | async)?.payload?.page?.length > 0" class="btn btn-primary deselect" (click)="deselectAll()">{{'admin.registries.bitstream-formats.table.deselect-all' | translate}}</button>
<button *ngIf="(bitstreamFormats$ | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFormats()">{{'admin.registries.bitstream-formats.table.delete' | translate}}</button>
</div>
</div>
</div>

View File

@@ -8,10 +8,7 @@ import { By } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
cold,
hot,
} from 'jasmine-marbles';
import { hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
@@ -191,17 +188,17 @@ describe('BitstreamFormatsComponent', () => {
beforeEach(waitForAsync(initAsync));
beforeEach(initBeforeEach);
it('should return an observable of true if the provided bitstream is in the list returned by the service', () => {
const result = comp.isSelected(bitstreamFormat1);
expect(result).toBeObservable(cold('b', { b: true }));
comp.selectedBitstreamFormatIDs().subscribe((selectedBitstreamFormatIDs: string[]) => {
expect(selectedBitstreamFormatIDs).toContain(bitstreamFormat1.id);
});
});
it('should return an observable of false if the provided bitstream is not in the list returned by the service', () => {
const format = new BitstreamFormat();
format.uuid = 'new';
const result = comp.isSelected(format);
expect(result).toBeObservable(cold('b', { b: false }));
comp.selectedBitstreamFormatIDs().subscribe((selectedBitstreamFormatIDs: string[]) => {
expect(selectedBitstreamFormatIDs).not.toContain(format.id);
});
});
});

View File

@@ -13,10 +13,7 @@ import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
combineLatest as observableCombineLatest,
Observable,
} from 'rxjs';
import { Observable } from 'rxjs';
import {
map,
mergeMap,
@@ -58,7 +55,12 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
/**
* A paginated list of bitstream formats to be shown on the page
*/
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
bitstreamFormats$: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
/**
* The currently selected {@link BitstreamFormat} IDs
*/
selectedBitstreamFormatIDs$: Observable<string[]>;
/**
* The current pagination configuration for the page
@@ -125,14 +127,11 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
}
/**
* Checks whether a given bitstream format is selected in the list (checkbox)
* @param bitstreamFormat
* Returns the list of all the bitstream formats that are selected in the list (checkbox)
*/
isSelected(bitstreamFormat: BitstreamFormat): Observable<boolean> {
selectedBitstreamFormatIDs(): Observable<string[]> {
return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(
map((bitstreamFormats: BitstreamFormat[]) => {
return bitstreamFormats.find((selectedFormat) => selectedFormat.id === bitstreamFormat.id) != null;
}),
map((bitstreamFormats: BitstreamFormat[]) => bitstreamFormats.map((selectedFormat) => selectedFormat.id)),
);
}
@@ -156,27 +155,23 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
const prefix = 'admin.registries.bitstream-formats.delete';
const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest(
this.translateService.get(`${prefix}.${suffix}.head`),
this.translateService.get(`${prefix}.${suffix}.amount`, { amount: amount }),
);
messages.subscribe(([head, content]) => {
const head: string = this.translateService.instant(`${prefix}.${suffix}.head`);
const content: string = this.translateService.instant(`${prefix}.${suffix}.amount`, { amount: amount });
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
});
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
}
ngOnInit(): void {
this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe(
this.bitstreamFormats$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe(
switchMap((findListOptions: FindListOptions) => {
return this.bitstreamFormatService.findAll(findListOptions);
}),
);
this.selectedBitstreamFormatIDs$ = this.selectedBitstreamFormatIDs();
}

View File

@@ -27,14 +27,14 @@
</thead>
<tbody>
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
[ngClass]="{'table-primary' : isActive(schema) | async}">
[ngClass]="{'table-primary' : (activeMetadataSchema$ | async)?.id === schema.id}">
<td>
<label class="mb-0">
<input type="checkbox"
[checked]="isSelected(schema) | async"
[checked]="(selectedMetadataSchemaIDs$ | async)?.includes(schema.id)"
(change)="selectMetadataSchema(schema, $event)"
>
<span class="sr-only">{{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span>
<span class="sr-only">{{(((selectedMetadataSchemaIDs$ | async)?.includes(schema.id)) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span>
</label>
</td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>

View File

@@ -17,9 +17,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service';
import { RestResponse } from '../../../core/cache/response.models';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
@@ -36,7 +34,9 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.u
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { RegistryServiceStub } from '../../../shared/testing/registry.service.stub';
import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service.stub';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { MetadataRegistryComponent } from './metadata-registry.component';
import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-schema-form.component';
@@ -44,9 +44,11 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch
describe('MetadataRegistryComponent', () => {
let comp: MetadataRegistryComponent;
let fixture: ComponentFixture<MetadataRegistryComponent>;
let registryService: RegistryService;
let paginationService;
const mockSchemasList = [
let paginationService: PaginationServiceStub;
let registryService: RegistryServiceStub;
const mockSchemasList: MetadataSchema[] = [
{
id: 1,
_links: {
@@ -67,25 +69,7 @@ describe('MetadataRegistryComponent', () => {
prefix: 'mock',
namespace: 'http://dspace.org/mockschema',
},
];
const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList));
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas,
getActiveMetadataSchema: () => observableOf(undefined),
getSelectedMetadataSchemas: () => observableOf([]),
editMetadataSchema: (schema) => {
},
cancelEditMetadataSchema: () => {
},
deleteMetadataSchema: () => observableOf(new RestResponse(true, 200, 'OK')),
deselectAllMetadataSchema: () => {
},
clearMetadataSchemaRequests: () => observableOf(undefined),
};
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
paginationService = new PaginationServiceStub();
] as MetadataSchema[];
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
@@ -109,6 +93,10 @@ describe('MetadataRegistryComponent', () => {
);
beforeEach(waitForAsync(() => {
paginationService = new PaginationServiceStub();
registryService = new RegistryServiceStub();
spyOn(registryService, 'getMetadataSchemas').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)));
TestBed.configureTestingModule({
imports: [
CommonModule,
@@ -120,7 +108,7 @@ describe('MetadataRegistryComponent', () => {
EnumKeysPipe,
],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: RegistryService, useValue: registryService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService },
{
@@ -190,7 +178,7 @@ describe('MetadataRegistryComponent', () => {
}));
it('should cancel editing the selected schema when clicked again', waitForAsync(() => {
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(mockSchemasList[0] as MetadataSchema));
comp.activeMetadataSchema$ = observableOf(mockSchemasList[0] as MetadataSchema);
spyOn(registryService, 'cancelEditMetadataSchema');
row.click();
fixture.detectChanges();
@@ -205,7 +193,7 @@ describe('MetadataRegistryComponent', () => {
beforeEach(() => {
spyOn(registryService, 'deleteMetadataSchema').and.callThrough();
spyOn(registryService, 'getSelectedMetadataSchemas').and.returnValue(observableOf(selectedSchemas as MetadataSchema[]));
comp.selectedMetadataSchemaIDs$ = observableOf(selectedSchemas.map((selectedSchema: MetadataSchema) => selectedSchema.id));
comp.deleteSchemas();
fixture.detectChanges();
});

View File

@@ -7,19 +7,17 @@ import {
import {
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import {
Router,
RouterLink,
} from '@angular/router';
import { RouterLink } from '@angular/router';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
Observable,
Subscription,
zip,
} from 'rxjs';
import {
@@ -36,7 +34,6 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
import { RegistryService } from '../../../core/registry/registry.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { toFindListOptions } from '../../../shared/pagination/pagination.utils';
@@ -63,13 +60,23 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch
* A component used for managing all existing metadata schemas within the repository.
* The admin can create, edit or delete metadata schemas here.
*/
export class MetadataRegistryComponent implements OnDestroy {
export class MetadataRegistryComponent implements OnDestroy, OnInit {
/**
* A list of all the current metadata schemas within the repository
*/
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>;
/**
* The {@link MetadataSchema}that is being edited
*/
activeMetadataSchema$: Observable<MetadataSchema>;
/**
* The selected {@link MetadataSchema} IDs
*/
selectedMetadataSchemaIDs$: Observable<number[]>;
/**
* Pagination config used to display the list of metadata schemas
*/
@@ -79,15 +86,25 @@ export class MetadataRegistryComponent implements OnDestroy {
});
/**
* Whether or not the list of MetadataSchemas needs an update
* Whether the list of MetadataSchemas needs an update
*/
needsUpdate$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
constructor(private registryService: RegistryService,
private notificationsService: NotificationsService,
private router: Router,
private paginationService: PaginationService,
private translateService: TranslateService) {
subscriptions: Subscription[] = [];
constructor(
protected registryService: RegistryService,
protected notificationsService: NotificationsService,
protected paginationService: PaginationService,
protected translateService: TranslateService,
) {
}
ngOnInit(): void {
this.activeMetadataSchema$ = this.registryService.getActiveMetadataSchema();
this.selectedMetadataSchemaIDs$ = this.registryService.getSelectedMetadataSchemas().pipe(
map((schemas: MetadataSchema[]) => schemas.map((schema: MetadataSchema) => schema.id)),
);
this.updateSchemas();
}
@@ -116,30 +133,13 @@ export class MetadataRegistryComponent implements OnDestroy {
* @param schema
*/
editSchema(schema: MetadataSchema) {
this.getActiveSchema().pipe(take(1)).subscribe((activeSchema) => {
this.subscriptions.push(this.activeMetadataSchema$.pipe(take(1)).subscribe((activeSchema: MetadataSchema) => {
if (schema === activeSchema) {
this.registryService.cancelEditMetadataSchema();
} else {
this.registryService.editMetadataSchema(schema);
}
});
}
/**
* Checks whether the given metadata schema is active (being edited)
* @param schema
*/
isActive(schema: MetadataSchema): Observable<boolean> {
return this.getActiveSchema().pipe(
map((activeSchema) => schema === activeSchema),
);
}
/**
* Gets the active metadata schema (being edited)
*/
getActiveSchema(): Observable<MetadataSchema> {
return this.registryService.getActiveMetadataSchema();
}));
}
/**
@@ -153,42 +153,25 @@ export class MetadataRegistryComponent implements OnDestroy {
this.registryService.deselectMetadataSchema(schema);
}
/**
* Checks whether a given metadata schema is selected in the list (checkbox)
* @param schema
*/
isSelected(schema: MetadataSchema): Observable<boolean> {
return this.registryService.getSelectedMetadataSchemas().pipe(
map((schemas) => schemas.find((selectedSchema) => selectedSchema === schema) != null),
);
}
/**
* Delete all the selected metadata schemas
*/
deleteSchemas() {
this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe(
(schemas) => {
const tasks$ = [];
for (const schema of schemas) {
if (hasValue(schema.id)) {
tasks$.push(this.registryService.deleteMetadataSchema(schema.id).pipe(getFirstCompletedRemoteData()));
}
}
zip(...tasks$).subscribe((responses: RemoteData<NoContent>[]) => {
const successResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) {
this.showNotification(true, successResponses.length);
}
if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length);
}
this.registryService.deselectAllMetadataSchema();
this.registryService.cancelEditMetadataSchema();
});
},
);
this.subscriptions.push(this.selectedMetadataSchemaIDs$.pipe(
take(1),
switchMap((schemaIDs: number[]) => zip(schemaIDs.map((schemaID: number) => this.registryService.deleteMetadataSchema(schemaID).pipe(getFirstCompletedRemoteData())))),
).subscribe((responses: RemoteData<NoContent>[]) => {
const successResponses: RemoteData<NoContent>[] = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
const failedResponses: RemoteData<NoContent>[] = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) {
this.showNotification(true, successResponses.length);
}
if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length);
}
this.registryService.deselectAllMetadataSchema();
this.registryService.cancelEditMetadataSchema();
}));
}
/**
@@ -199,20 +182,20 @@ export class MetadataRegistryComponent implements OnDestroy {
showNotification(success: boolean, amount: number) {
const prefix = 'admin.registries.schema.notification';
const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest(
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
this.translateService.get(`${prefix}.deleted.${suffix}`, { amount: amount }),
);
messages.subscribe(([head, content]) => {
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
});
const head: string = this.translateService.instant(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`);
const content: string = this.translateService.instant(`${prefix}.deleted.${suffix}`, { amount: amount });
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
}
ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
}
}

View File

@@ -1,4 +1,4 @@
<div *ngIf="registryService.getActiveMetadataSchema() | async; then editheader; else createHeader"></div>
<div *ngIf="activeMetadataSchema$ | async; then editheader; else createHeader"></div>
<ng-template #createHeader>
<h2>{{messagePrefix + '.create' | translate}}</h2>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
inject,
@@ -16,42 +16,26 @@ import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { FormComponent } from '../../../../shared/form/form.component';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { RegistryServiceStub } from '../../../../shared/testing/registry.service.stub';
import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
import { MetadataSchemaFormComponent } from './metadata-schema-form.component';
describe('MetadataSchemaFormComponent', () => {
let component: MetadataSchemaFormComponent;
let fixture: ComponentFixture<MetadataSchemaFormComponent>;
let registryService: RegistryService;
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = {
getActiveMetadataSchema: () => observableOf(undefined),
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
cancelEditMetadataSchema: () => {
},
clearMetadataSchemaRequests: () => observableOf(undefined),
};
const formBuilderServiceStub = {
createFormGroup: () => {
return {
patchValue: () => {
},
reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void {
},
};
},
};
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
let registryService: RegistryServiceStub;
beforeEach(waitForAsync(() => {
registryService = new RegistryServiceStub();
return TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataSchemaFormComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: RegistryService, useValue: registryService },
{ provide: FormBuilderService, useValue: getMockFormBuilderService() },
],
schemas: [NO_ERRORS_SCHEMA],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(MetadataSchemaFormComponent, {
remove: {
@@ -88,7 +72,7 @@ describe('MetadataSchemaFormComponent', () => {
describe('without an active schema', () => {
beforeEach(() => {
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(undefined));
component.activeMetadataSchema$ = observableOf(undefined);
component.onSubmit();
fixture.detectChanges();
});
@@ -107,7 +91,7 @@ describe('MetadataSchemaFormComponent', () => {
} as MetadataSchema);
beforeEach(() => {
spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId));
component.activeMetadataSchema$ = observableOf(expectedWithId);
component.onSubmit();
fixture.detectChanges();
});

View File

@@ -21,13 +21,13 @@ import {
TranslateService,
} from '@ngx-translate/core';
import {
combineLatest,
Observable,
Subscription,
} from 'rxjs';
import {
map,
switchMap,
take,
tap,
} from 'rxjs/operators';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
@@ -102,64 +102,71 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
*/
@Output() submitForm: EventEmitter<any> = new EventEmitter();
constructor(public registryService: RegistryService, private formBuilderService: FormBuilderService, private translateService: TranslateService) {
/**
* The {@link MetadataSchema} that is currently being edited
*/
activeMetadataSchema$: Observable<MetadataSchema>;
subscriptions: Subscription[] = [];
constructor(
protected registryService: RegistryService,
protected formBuilderService: FormBuilderService,
protected translateService: TranslateService,
) {
}
ngOnInit() {
combineLatest([
this.translateService.get(`${this.messagePrefix}.name`),
this.translateService.get(`${this.messagePrefix}.namespace`),
]).subscribe(([name, namespace]) => {
this.name = new DynamicInputModel({
id: 'name',
label: name,
name: 'name',
validators: {
required: null,
pattern: '^[^. ,]*$',
maxLength: 32,
},
required: true,
errorMessages: {
pattern: 'error.validation.metadata.name.invalid-pattern',
maxLength: 'error.validation.metadata.name.max-length',
},
});
this.namespace = new DynamicInputModel({
id: 'namespace',
label: namespace,
name: 'namespace',
validators: {
required: null,
maxLength: 256,
},
required: true,
errorMessages: {
maxLength: 'error.validation.metadata.namespace.max-length',
},
});
this.formModel = [
new DynamicFormGroupModel(
{
id: 'metadatadataschemagroup',
group:[this.namespace, this.name],
}),
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.registryService.getActiveMetadataSchema().subscribe((schema: MetadataSchema) => {
if (schema == null) {
this.clearFields();
} else {
this.formGroup.patchValue({
metadatadataschemagroup: {
name: schema.prefix,
namespace: schema.namespace,
},
});
this.name.disabled = true;
}
});
this.name = new DynamicInputModel({
id: 'name',
label: this.translateService.instant(`${this.messagePrefix}.name`),
name: 'name',
validators: {
required: null,
pattern: '^[^. ,]*$',
maxLength: 32,
},
required: true,
errorMessages: {
pattern: 'error.validation.metadata.name.invalid-pattern',
maxLength: 'error.validation.metadata.name.max-length',
},
});
this.namespace = new DynamicInputModel({
id: 'namespace',
label: this.translateService.instant(`${this.messagePrefix}.namespace`),
name: 'namespace',
validators: {
required: null,
maxLength: 256,
},
required: true,
errorMessages: {
maxLength: 'error.validation.metadata.namespace.max-length',
},
});
this.formModel = [
new DynamicFormGroupModel(
{
id: 'metadatadataschemagroup',
group:[this.namespace, this.name],
}),
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.activeMetadataSchema$ = this.registryService.getActiveMetadataSchema();
this.subscriptions.push(this.activeMetadataSchema$.subscribe((schema: MetadataSchema) => {
if (schema == null) {
this.clearFields();
} else {
this.formGroup.patchValue({
metadatadataschemagroup: {
name: schema.prefix,
namespace: schema.namespace,
},
});
this.name.disabled = true;
}
}));
}
/**
@@ -176,48 +183,29 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
* Emit the updated/created schema using the EventEmitter submitForm
*/
onSubmit(): void {
this.registryService
.getActiveMetadataSchema()
.pipe(
take(1),
switchMap((schema: MetadataSchema) => {
const metadataValues = {
prefix: this.name.value,
namespace: this.namespace.value,
};
let createOrUpdate$: Observable<MetadataSchema>;
if (schema == null) {
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
Object.assign(new MetadataSchema(), metadataValues),
);
} else {
const updatedSchema = Object.assign(
new MetadataSchema(),
schema,
{
namespace: metadataValues.namespace,
},
);
createOrUpdate$ =
this.registryService.createOrUpdateMetadataSchema(
updatedSchema,
);
}
return createOrUpdate$;
}),
tap(() => {
this.registryService.clearMetadataSchemaRequests().subscribe();
}),
)
.subscribe((updatedOrCreatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedOrCreatedSchema);
this.clearFields();
this.registryService.cancelEditMetadataSchema();
});
this.activeMetadataSchema$.pipe(
take(1),
switchMap((schema: MetadataSchema) => {
const metadataValues = {
prefix: this.name.value,
namespace: this.namespace.value,
};
if (schema == null) {
return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), metadataValues));
} else {
return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, {
namespace: metadataValues.namespace,
}));
}
}),
switchMap((updatedOrCreatedSchema: MetadataSchema) => this.registryService.clearMetadataSchemaRequests().pipe(
map(() => updatedOrCreatedSchema),
)),
).subscribe((updatedOrCreatedSchema: MetadataSchema) => {
this.submitForm.emit(updatedOrCreatedSchema);
this.clearFields();
this.registryService.cancelEditMetadataSchema();
});
}
/**
@@ -233,5 +221,6 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
*/
ngOnDestroy(): void {
this.onCancel();
this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe());
}
}

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
inject,
@@ -17,13 +17,15 @@ import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { FormComponent } from '../../../../shared/form/form.component';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { RegistryServiceStub } from '../../../../shared/testing/registry.service.stub';
import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
import { MetadataFieldFormComponent } from './metadata-field-form.component';
describe('MetadataFieldFormComponent', () => {
let component: MetadataFieldFormComponent;
let fixture: ComponentFixture<MetadataFieldFormComponent>;
let registryService: RegistryService;
let registryService: RegistryServiceStub;
const metadataSchema = Object.assign(new MetadataSchema(), {
id: 1,
@@ -31,37 +33,16 @@ describe('MetadataFieldFormComponent', () => {
prefix: 'fake',
});
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = {
getActiveMetadataField: () => observableOf(undefined),
createMetadataField: (field: MetadataField) => observableOf(field),
updateMetadataField: (field: MetadataField) => observableOf(field),
cancelEditMetadataField: () => {
},
cancelEditMetadataSchema: () => {
},
clearMetadataFieldRequests: () => observableOf(undefined),
};
const formBuilderServiceStub = {
createFormGroup: () => {
return {
patchValue: () => {
},
reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void {
},
};
},
};
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
beforeEach(waitForAsync(() => {
registryService = new RegistryServiceStub();
return TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataFieldFormComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: RegistryService, useValue: registryService },
{ provide: FormBuilderService, useValue: getMockFormBuilderService() },
],
schemas: [NO_ERRORS_SCHEMA],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(MetadataFieldFormComponent, {
remove: { imports: [FormComponent] },

View File

@@ -31,8 +31,8 @@
</thead>
<tbody>
<tr *ngFor="let field of fields?.page"
[ngClass]="{'table-primary' : isActive(field) | async}">
<td *ngVar="(isSelected(field) | async) as selected">
[ngClass]="{'table-primary' : (activeField$ | async)?.id === field.id}">
<td *ngVar="(selectedMetadataFieldIDs$ | async)?.includes(field.id) as selected">
<input type="checkbox"
[attr.aria-label]="(selected ? 'admin.registries.schema.fields.deselect' : 'admin.registries.schema.fields.select') | translate"
[checked]="selected"

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
inject,
@@ -7,16 +7,12 @@ import {
waitForAsync,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { RestResponse } from '../../../core/cache/response.models';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { GroupDataService } from '../../../core/eperson/group-data.service';
@@ -34,7 +30,7 @@ import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { RouterStub } from '../../../shared/testing/router.stub';
import { RegistryServiceStub } from '../../../shared/testing/registry.service.stub';
import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service.stub';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
@@ -45,8 +41,12 @@ import { MetadataSchemaComponent } from './metadata-schema.component';
describe('MetadataSchemaComponent', () => {
let comp: MetadataSchemaComponent;
let fixture: ComponentFixture<MetadataSchemaComponent>;
let registryService: RegistryService;
const mockSchemasList = [
let registryService: RegistryServiceStub;
let activatedRoute: ActivatedRouteStub;
let paginationService: PaginationServiceStub;
const mockSchemasList: MetadataSchema[] = [
{
id: 1,
_links: {
@@ -67,8 +67,8 @@ describe('MetadataSchemaComponent', () => {
prefix: 'mock',
namespace: 'http://dspace.org/mockschema',
},
];
const mockFieldsList = [
] as MetadataSchema[];
const mockFieldsList: MetadataField[] = [
{
id: 1,
_links: {
@@ -117,33 +117,8 @@ describe('MetadataSchemaComponent', () => {
scopeNote: null,
schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]),
},
];
const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList));
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas,
getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))),
getMetadataSchemaByPrefix: (schemaName: string) => createSuccessfulRemoteDataObject$(mockSchemasList.filter((value) => value.prefix === schemaName)[0]),
getActiveMetadataField: () => observableOf(undefined),
getSelectedMetadataFields: () => observableOf([]),
editMetadataField: (schema) => {
},
cancelEditMetadataField: () => {
},
deleteMetadataField: () => observableOf(new RestResponse(true, 200, 'OK')),
deselectAllMetadataField: () => {
},
clearMetadataFieldRequests: () => observableOf(undefined),
};
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
] as MetadataField[];
const schemaNameParam = 'mock';
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: observableOf({
schemaName: schemaNameParam,
}),
});
const paginationService = new PaginationServiceStub();
const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
@@ -162,6 +137,14 @@ describe('MetadataSchemaComponent', () => {
beforeEach(waitForAsync(() => {
activatedRoute = new ActivatedRouteStub({
schemaName: schemaNameParam,
});
paginationService = new PaginationServiceStub();
registryService = new RegistryServiceStub();
spyOn(registryService, 'getMetadataFieldsBySchema').and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))));
spyOn(registryService, 'getMetadataSchemaByPrefix').and.callFake((schemaName) => createSuccessfulRemoteDataObject$(mockSchemasList.filter((value) => value.prefix === schemaName)[0]));
TestBed.configureTestingModule({
imports: [
CommonModule,
@@ -174,10 +157,9 @@ describe('MetadataSchemaComponent', () => {
VarDirective,
],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: RegistryService, useValue: registryService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: Router, useValue: new RouterStub() },
{ provide: PaginationService, useValue: paginationService },
{
provide: NotificationsService,
@@ -187,7 +169,7 @@ describe('MetadataSchemaComponent', () => {
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
],
schemas: [NO_ERRORS_SCHEMA],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(MetadataSchemaComponent, {
remove: {
@@ -242,7 +224,7 @@ describe('MetadataSchemaComponent', () => {
}));
it('should cancel editing the selected field when clicked again', waitForAsync(() => {
spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(mockFieldsList[2] as MetadataField));
comp.activeField$ = observableOf(mockFieldsList[2] as MetadataField);
spyOn(registryService, 'cancelEditMetadataField');
row.click();
fixture.detectChanges();
@@ -257,7 +239,7 @@ describe('MetadataSchemaComponent', () => {
beforeEach(() => {
spyOn(registryService, 'deleteMetadataField').and.callThrough();
spyOn(registryService, 'getSelectedMetadataFields').and.returnValue(observableOf(selectedFields as MetadataField[]));
comp.selectedMetadataFieldIDs$ = observableOf(selectedFields.map((metadataField: MetadataField) => metadataField.id));
comp.deleteFields();
fixture.detectChanges();
});

View File

@@ -20,9 +20,9 @@ import {
import {
BehaviorSubject,
combineLatest,
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription,
zip,
} from 'rxjs';
import {
@@ -42,7 +42,6 @@ import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { toFindListOptions } from '../../../shared/pagination/pagination.utils';
@@ -71,7 +70,7 @@ import { MetadataFieldFormComponent } from './metadata-field-form/metadata-field
* A component used for managing all existing metadata fields within the current metadata schema.
* The admin can create, edit or delete metadata fields here.
*/
export class MetadataSchemaComponent implements OnInit, OnDestroy {
export class MetadataSchemaComponent implements OnDestroy, OnInit {
/**
* The metadata schema
*/
@@ -96,26 +95,33 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy {
*/
needsUpdate$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
constructor(private registryService: RegistryService,
private route: ActivatedRoute,
private notificationsService: NotificationsService,
private paginationService: PaginationService,
private translateService: TranslateService) {
/**
* The current {@link MetadataField} that is being edited
*/
activeField$: Observable<MetadataField>;
/**
* The selected {@link MetadataField} IDs
*/
selectedMetadataFieldIDs$: Observable<number[]>;
subscriptions: Subscription[] = [];
constructor(
protected registryService: RegistryService,
protected route: ActivatedRoute,
protected notificationsService: NotificationsService,
protected paginationService: PaginationService,
protected translateService: TranslateService,
) {
}
ngOnInit(): void {
this.route.params.subscribe((params) => {
this.initialize(params);
});
}
/**
* Initialize the component using the params within the url (schemaName)
* @param params
*/
initialize(params) {
this.metadataSchema$ = this.registryService.getMetadataSchemaByPrefix(params.schemaName).pipe(getFirstSucceededRemoteDataPayload());
this.metadataSchema$ = this.registryService.getMetadataSchemaByPrefix(this.route.snapshot.params.schemaName).pipe(getFirstSucceededRemoteDataPayload());
this.activeField$ = this.registryService.getActiveMetadataField();
this.selectedMetadataFieldIDs$ = this.registryService.getSelectedMetadataFields().pipe(
map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => metadataField.id)),
);
this.updateFields();
}
@@ -148,30 +154,13 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy {
* @param field
*/
editField(field: MetadataField) {
this.getActiveField().pipe(take(1)).subscribe((activeField) => {
this.subscriptions.push(this.activeField$.pipe(take(1)).subscribe((activeField) => {
if (field === activeField) {
this.registryService.cancelEditMetadataField();
} else {
this.registryService.editMetadataField(field);
}
});
}
/**
* Checks whether the given metadata field is active (being edited)
* @param field
*/
isActive(field: MetadataField): Observable<boolean> {
return this.getActiveField().pipe(
map((activeField) => field === activeField),
);
}
/**
* Gets the active metadata field (being edited)
*/
getActiveField(): Observable<MetadataField> {
return this.registryService.getActiveMetadataField();
}));
}
/**
@@ -185,42 +174,25 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy {
this.registryService.deselectMetadataField(field);
}
/**
* Checks whether a given metadata field is selected in the list (checkbox)
* @param field
*/
isSelected(field: MetadataField): Observable<boolean> {
return this.registryService.getSelectedMetadataFields().pipe(
map((fields) => fields.find((selectedField) => selectedField === field) != null),
);
}
/**
* Delete all the selected metadata fields
*/
deleteFields() {
this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe(
(fields) => {
const tasks$ = [];
for (const field of fields) {
if (hasValue(field.id)) {
tasks$.push(this.registryService.deleteMetadataField(field.id).pipe(getFirstCompletedRemoteData()));
}
}
zip(...tasks$).subscribe((responses: RemoteData<NoContent>[]) => {
const successResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) {
this.showNotification(true, successResponses.length);
}
if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length);
}
this.registryService.deselectAllMetadataField();
this.registryService.cancelEditMetadataField();
});
},
);
this.subscriptions.push(this.selectedMetadataFieldIDs$.pipe(
take(1),
switchMap((fieldIDs) => zip(fieldIDs.map((fieldID) => this.registryService.deleteMetadataField(fieldID).pipe(getFirstCompletedRemoteData())))),
).subscribe((responses: RemoteData<NoContent>[]) => {
const successResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
if (successResponses.length > 0) {
this.showNotification(true, successResponses.length);
}
if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length);
}
this.registryService.deselectAllMetadataField();
this.registryService.cancelEditMetadataField();
}));
}
/**
@@ -231,21 +203,19 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy {
showNotification(success: boolean, amount: number) {
const prefix = 'admin.registries.schema.notification';
const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest([
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`),
this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount }),
]);
messages.subscribe(([head, content]) => {
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
});
const head = this.translateService.instant(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`);
const content = this.translateService.instant(`${prefix}.field.deleted.${suffix}`, { amount: amount });
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
}
ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id);
this.registryService.deselectAllMetadataField();
this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe());
}
}

View File

@@ -54,7 +54,7 @@
<div class="col-auto">
<button class="btn btn-light" (click)="addQueryPredicate()">+</button>
&nbsp;
<button class="btn btn-light" [disabled]="deleteQueryPredicateDisabled()" (click)="deleteQueryPredicate(i)"></button>
<button class="btn btn-light" [dsBtnDisabled]="deleteQueryPredicateDisabled()" (click)="deleteQueryPredicate(i)"></button>
</div>
</div>
</div>
@@ -158,8 +158,8 @@
{{'admin.reports.commons.page' | translate}} {{ currentPage + 1 }} {{'admin.reports.commons.of' | translate}} {{ pageCount() }}
</div>
<div>
<button id="prev" class="btn btn-light" (click)="prevPage()" [disabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
<button id="next" class="btn btn-light" (click)="nextPage()" [disabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
<!--
<button id="export">{{'admin.reports.commons.export' | translate}}</button>
-->

View File

@@ -43,6 +43,7 @@ import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operator
import { isEmpty } from 'src/app/shared/empty.util';
import { environment } from 'src/environments/environment';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { FiltersComponent } from '../filters-section/filters-section.component';
import { FilteredItems } from './filtered-items-model';
import { OptionVO } from './option-vo.model';
@@ -64,6 +65,7 @@ import { QueryPredicate } from './query-predicate.model';
NgIf,
NgForOf,
FiltersComponent,
BtnDisabledDirective,
],
standalone: true,
})

View File

@@ -11,8 +11,8 @@ import {
REGISTRIES_MODULE_PATH,
REPORTS_MODULE_PATH,
} from './admin-routing-paths';
import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component';
import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component';
import { ThemedAdminSearchPageComponent } from './admin-search-page/themed-admin-search-page.component';
import { ThemedAdminWorkflowPageComponent } from './admin-workflow-page/themed-admin-workflow-page.component';
export const ROUTES: Route[] = [
{
@@ -28,13 +28,13 @@ export const ROUTES: Route[] = [
{
path: 'search',
resolve: { breadcrumb: i18nBreadcrumbResolver },
component: AdminSearchPageComponent,
component: ThemedAdminSearchPageComponent,
data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' },
},
{
path: 'workflow',
resolve: { breadcrumb: i18nBreadcrumbResolver },
component: AdminWorkflowPageComponent,
component: ThemedAdminWorkflowPageComponent,
data: { title: 'admin.workflow.title', breadcrumbKey: 'admin.workflow' },
},
{

View File

@@ -4,7 +4,7 @@ import { Context } from '../../core/shared/context.model';
import { ThemedConfigurationSearchPageComponent } from '../../search-page/themed-configuration-search-page.component';
@Component({
selector: 'ds-admin-search-page',
selector: 'ds-base-admin-search-page',
templateUrl: './admin-search-page.component.html',
styleUrls: ['./admin-search-page.component.scss'],
standalone: true,

View File

@@ -0,0 +1,29 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { AdminSearchPageComponent } from './admin-search-page.component';
/**
* Themed wrapper for {@link AdminSearchPageComponent}
*/
@Component({
selector: 'ds-admin-search-page',
templateUrl: '../../shared/theme-support/themed.component.html',
standalone: true,
imports: [AdminSearchPageComponent],
})
export class ThemedAdminSearchPageComponent extends ThemedComponent<AdminSearchPageComponent> {
protected getComponentName(): string {
return 'AdminSearchPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/admin/admin-search-page/admin-search-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./admin-search-page.component');
}
}

View File

@@ -14,7 +14,9 @@
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
<span [id]="adminMenuSectionTitleId(section.id)">{{itemModel.text | translate}}</span>
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
{{itemModel.text | translate}}
</span>
</div>
</div>
</a>

View File

@@ -17,6 +17,7 @@ import { MenuID } from '../../../shared/menu/menu-id.model';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu-section.model';
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
/**
* Represents a non-expandable section in the admin sidebar
@@ -26,7 +27,7 @@ import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-sec
templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'],
standalone: true,
imports: [NgClass, RouterLink, TranslateModule],
imports: [NgClass, RouterLink, TranslateModule, BrowserOnlyPipe],
})
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {

View File

@@ -42,6 +42,7 @@
<div class="sidebar-full-width-container" id="sidebar-collapse-toggle-container">
<a class="sidebar-section-wrapper sidebar-full-width-container"
id="sidebar-collapse-toggle"
[attr.data-test]="'sidebar-collapse-toggle' | dsBrowserOnly"
href="javascript:void(0);"
(click)="toggle($event)"
(keyup.space)="toggle($event)"

View File

@@ -36,6 +36,7 @@ import { MenuService } from '../../shared/menu/menu.service';
import { MenuID } from '../../shared/menu/menu-id.model';
import { CSSVariableService } from '../../shared/sass-helper/css-variable.service';
import { ThemeService } from '../../shared/theme-support/theme.service';
import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe';
/**
* Component representing the admin sidebar
@@ -46,7 +47,7 @@ import { ThemeService } from '../../shared/theme-support/theme.service';
styleUrls: ['./admin-sidebar.component.scss'],
animations: [slideSidebar],
standalone: true,
imports: [NgIf, NgbDropdownModule, NgClass, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule],
imports: [NgIf, NgbDropdownModule, NgClass, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule, BrowserOnlyPipe],
})
export class AdminSidebarComponent extends MenuComponent implements OnInit {
/**

View File

@@ -19,7 +19,7 @@
</div>
<div class="sidebar-collapsible-element-outer-wrapper">
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item toggler-wrapper">
<span [id]="adminMenuSectionTitleId(section.id)">
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>

View File

@@ -25,6 +25,7 @@ import { slide } from '../../../shared/animations/slide';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
/**
@@ -36,7 +37,7 @@ import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sid
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor],
standalone: true,
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule],
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule, BrowserOnlyPipe],
})
export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit {
@@ -84,7 +85,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
this.isExpanded$ = combineLatestObservable([this.active$, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe(
map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))),
);
}

View File

@@ -4,11 +4,13 @@ import { Context } from '../../core/shared/context.model';
import { ThemedConfigurationSearchPageComponent } from '../../search-page/themed-configuration-search-page.component';
@Component({
selector: 'ds-admin-workflow-page',
selector: 'ds-base-admin-workflow-page',
templateUrl: './admin-workflow-page.component.html',
styleUrls: ['./admin-workflow-page.component.scss'],
standalone: true,
imports: [ThemedConfigurationSearchPageComponent],
imports: [
ThemedConfigurationSearchPageComponent,
],
})
/**

View File

@@ -0,0 +1,29 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { AdminWorkflowPageComponent } from './admin-workflow-page.component';
/**
* Themed wrapper for {@link AdminWorkflowPageComponent}
*/
@Component({
selector: 'ds-admin-workflow-page',
templateUrl: '../../shared/theme-support/themed.component.html',
standalone: true,
imports: [AdminWorkflowPageComponent],
})
export class ThemedAdminWorkflowPageComponent extends ThemedComponent<AdminWorkflowPageComponent> {
protected getComponentName(): string {
return 'AdminWorkflowPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/admin/admin-workflow-page/admin-workflow-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./admin-workflow-page.component');
}
}

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