Merge branch 'main' into task/main/DSC-1966

# Conflicts:
#	src/themes/dspace/app/header/header.component.html
#	src/themes/dspace/app/navbar/navbar.component.html
This commit is contained in:
Andrea Barbasso
2025-04-28 17:04:32 +02:00
1637 changed files with 211790 additions and 46991 deletions

View File

@@ -12,7 +12,6 @@
"eslint-plugin-rxjs", "eslint-plugin-rxjs",
"eslint-plugin-simple-import-sort", "eslint-plugin-simple-import-sort",
"eslint-plugin-import-newlines", "eslint-plugin-import-newlines",
"eslint-plugin-jsonc",
"dspace-angular-ts", "dspace-angular-ts",
"dspace-angular-html" "dspace-angular-html"
], ],
@@ -293,7 +292,9 @@
], ],
"rules": { "rules": {
// Custom DSpace Angular 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",
"@angular-eslint/template/prefer-control-flow": "error"
} }
}, },
{ {
@@ -301,10 +302,13 @@
"*.json5" "*.json5"
], ],
"extends": [ "extends": [
"plugin:jsonc/recommended-with-jsonc" "plugin:jsonc/recommended-with-json5"
], ],
"rules": { "rules": {
"no-irregular-whitespace": "error", // The ESLint core no-irregular-whitespace rule doesn't work well in JSON
// See: https://ota-meshi.github.io/eslint-plugin-jsonc/rules/no-irregular-whitespace.html
"no-irregular-whitespace": "off",
"jsonc/no-irregular-whitespace": "error",
"no-trailing-spaces": "error", "no-trailing-spaces": "error",
"jsonc/comma-dangle": [ "jsonc/comma-dangle": [
"error", "error",

155
.github/dependabot.yml vendored
View File

@@ -1,12 +1,16 @@
# Enable Dependabot NPM updates for all dependencies on a weekly basis #-------------------
# 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 version: 2
updates: updates:
############### ###############
## Main branch ## 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" - package-ecosystem: "npm"
directory: "/" directory: "/"
target-branch: main
schedule: schedule:
interval: "weekly" interval: "weekly"
# Allow up to 10 open PRs for dependencies # Allow up to 10 open PRs for dependencies
@@ -68,6 +72,7 @@ updates:
applies-to: version-updates applies-to: version-updates
patterns: patterns:
- "@cypress*" - "@cypress*"
- "axe-*"
- "cypress*" - "cypress*"
- "jasmine*" - "jasmine*"
- "karma*" - "karma*"
@@ -76,10 +81,11 @@ updates:
- "minor" - "minor"
- "patch" - "patch"
# Group together any testing related security updates # Group together any testing related security updates
testing: testing-security:
applies-to: security-updates applies-to: security-updates
patterns: patterns:
- "@cypress*" - "@cypress*"
- "axe-*"
- "cypress*" - "cypress*"
- "jasmine*" - "jasmine*"
- "karma*" - "karma*"
@@ -96,7 +102,7 @@ updates:
- "minor" - "minor"
- "patch" - "patch"
# Group together any postcss related security updates # Group together any postcss related security updates
postcss: postcss-security:
applies-to: security-updates applies-to: security-updates
patterns: patterns:
- "postcss*" - "postcss*"
@@ -112,8 +118,8 @@ updates:
- "minor" - "minor"
- "patch" - "patch"
# Group together any sass related security updates # Group together any sass related security updates
sass: sass-security:
applies-to: version-updates applies-to: security-updates
patterns: patterns:
- "sass*" - "sass*"
update-types: update-types:
@@ -128,7 +134,7 @@ updates:
- "minor" - "minor"
- "patch" - "patch"
# Group together any webpack related seurity updates # Group together any webpack related seurity updates
webpack: webpack-security:
applies-to: security-updates applies-to: security-updates
patterns: patterns:
- "webpack*" - "webpack*"
@@ -159,14 +165,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together all minor/patch version updates for NgRx in a single PR
ngrx: ngrx:
applies-to: version-updates applies-to: version-updates
@@ -175,14 +173,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together all patch version updates for eslint in a single PR
eslint: eslint:
applies-to: version-updates applies-to: version-updates
@@ -192,32 +182,12 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together any testing related version updates
testing: testing:
applies-to: version-updates applies-to: version-updates
patterns: patterns:
- "@cypress*" - "@cypress*"
- "cypress*" - "axe-*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any testing related security updates
testing:
applies-to: security-updates
patterns:
- "@cypress*"
- "cypress*" - "cypress*"
- "jasmine*" - "jasmine*"
- "karma*" - "karma*"
@@ -233,23 +203,7 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
# Group together any postcss related security updates
postcss:
applies-to: security-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any sass related version updates # 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: sass:
applies-to: version-updates applies-to: version-updates
patterns: patterns:
@@ -265,14 +219,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
# Group together any webpack related seurity updates
webpack:
applies-to: security-updates
patterns:
- "webpack*"
update-types:
- "minor"
- "patch"
ignore: ignore:
# Ignore all major version updates for all dependencies. We'll only automate minor/patch updates. # Ignore all major version updates for all dependencies. We'll only automate minor/patch updates.
- dependency-name: "*" - dependency-name: "*"
@@ -297,14 +243,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together all minor/patch version updates for NgRx in a single PR
ngrx: ngrx:
applies-to: version-updates applies-to: version-updates
@@ -313,14 +251,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together all patch version updates for eslint in a single PR
eslint: eslint:
applies-to: version-updates applies-to: version-updates
@@ -330,32 +260,12 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "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 # Group together any testing related version updates
testing: testing:
applies-to: version-updates applies-to: version-updates
patterns: patterns:
- "@cypress*" - "@cypress*"
- "cypress*" - "axe-*"
- "jasmine*"
- "karma*"
- "ng-mocks"
update-types:
- "minor"
- "patch"
# Group together any testing related security updates
testing:
applies-to: security-updates
patterns:
- "@cypress*"
- "cypress*" - "cypress*"
- "jasmine*" - "jasmine*"
- "karma*" - "karma*"
@@ -371,14 +281,6 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
# Group together any postcss related security updates
postcss:
applies-to: security-updates
patterns:
- "postcss*"
update-types:
- "minor"
- "patch"
# Group together any sass related version updates # Group together any sass related version updates
sass: sass:
applies-to: version-updates applies-to: version-updates
@@ -387,31 +289,10 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
# Group together any sass related security 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"
# Group together any webpack related seurity updates
webpack:
applies-to: security-updates
patterns:
- "webpack*"
update-types:
- "minor"
- "patch"
ignore: 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. # Ignore all major version updates for all dependencies. We'll only automate minor/patch updates.
- dependency-name: "*" - dependency-name: "*"
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]

View File

@@ -7,7 +7,8 @@ name: Build
on: [push, pull_request] on: [push, pull_request]
permissions: 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: jobs:
tests: tests:
@@ -35,6 +36,9 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096' NODE_OPTIONS: '--max-old-space-size=4096'
# Project name to use when running "docker compose" prior to e2e tests # Project name to use when running "docker compose" prior to e2e tests
COMPOSE_PROJECT_NAME: 'ci' 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: strategy:
# Create a matrix of Node versions to test against (in parallel) # Create a matrix of Node versions to test against (in parallel)
matrix: matrix:
@@ -114,6 +118,14 @@ jobs:
path: 'coverage/dspace-angular/lcov.info' path: 'coverage/dspace-angular/lcov.info'
retention-days: 14 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 # Using "docker compose" start backend using CI configuration
# and load assetstore from a cached copy # and load assetstore from a cached copy
- name: Start DSpace REST Backend via Docker (for e2e tests) - name: Start DSpace REST Backend via Docker (for e2e tests)
@@ -178,12 +190,84 @@ jobs:
# Get homepage and verify that the <meta name="title"> tag includes "DSpace". # Get homepage and verify that the <meta name="title"> tag includes "DSpace".
# If it does, then SSR is working, as this tag is created by our MetadataService. # If it does, then SSR is working, as this tag is created by our MetadataService.
# This step also prints entire HTML of homepage for easier debugging if grep fails. # This step also prints entire HTML of homepage for easier debugging if grep fails.
- name: Verify SSR (server-side rendering) - name: Verify SSR (server-side rendering) on Homepage
run: | run: |
result=$(wget -O- -q http://127.0.0.1:4000/home) result=$(wget -O- -q http://127.0.0.1:4000/home)
echo "$result" echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace
# Get a specific community in our test data and verify that the "<h1>" tag includes "Publications" (the community name).
# If it does, then SSR is working.
- name: Verify SSR on a Community page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/communities/0958c910-2037-42a9-81c7-dca80e3892b4)
echo "$result"
echo "$result" | grep -oE "<h1 [^>]*>[^><]*</h1>" | grep Publications
# Get a specific collection in our test data and verify that the "<h1>" tag includes "Articles" (the collection name).
# If it does, then SSR is working.
- name: Verify SSR on a Collection page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/collections/282164f5-d325-4740-8dd1-fa4d6d3e7200)
echo "$result"
echo "$result" | grep -oE "<h1 [^>]*>[^><]*</h1>" | grep Articles
# Get a specific publication in our test data and verify that the <meta name="title"> tag includes
# the title of this publication. If it does, then SSR is working.
- name: Verify SSR on a Publication page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/publication/6160810f-1e53-40db-81ef-f6621a727398)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "An Economic Model of Mortality Salience"
# Get a specific person in our test data and verify that the <meta name="title"> tag includes
# the name of the person. If it does, then SSR is working.
- name: Verify SSR on a Person page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/person/b1b2c768-bda1-448a-a073-fc541e8b24d9)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Simmons, Cameron"
# Get a specific project in our test data and verify that the <meta name="title"> tag includes
# the name of the project. If it does, then SSR is working.
- name: Verify SSR on a Project page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/project/46ccb608-a74c-4bf6-bc7a-e29cc7defea9)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "University Research Fellowship"
# Get a specific orgunit in our test data and verify that the <meta name="title"> tag includes
# the name of the orgunit. If it does, then SSR is working.
- name: Verify SSR on an OrgUnit page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/orgunit/9851674d-bd9a-467b-8d84-068deb568ccf)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Law and Development"
# Get a specific journal in our test data and verify that the <meta name="title"> tag includes
# the name of the journal. If it does, then SSR is working.
- name: Verify SSR on a Journal page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/journal/d4af6c3e-53d0-4757-81eb-566f3b45d63a)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental &amp; Architectural Phenomenology"
# Get a specific journal volume in our test data and verify that the <meta name="title"> tag includes
# the name of the volume. If it does, then SSR is working.
- name: Verify SSR on a Journal Volume page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/journalvolume/07c6249f-4bf7-494d-9ce3-6ffdb2aed538)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental &amp; Architectural Phenomenology Volume 28 (2017)"
# Get a specific journal issue in our test data and verify that the <meta name="title"> tag includes
# the name of the issue. If it does, then SSR is working.
- name: Verify SSR on a Journal Issue page
run: |
result=$(wget -O- -q http://127.0.0.1:4000/entities/journalissue/44c29473-5de2-48fa-b005-e5029aa1a50b)
echo "$result"
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental &amp; Architectural Phenomenology Vol. 28, No. 1"
- name: Stop running app - name: Stop running app
run: kill -9 $(lsof -t -i:4000) run: kill -9 $(lsof -t -i:4000)

View File

@@ -16,7 +16,8 @@ on:
pull_request: pull_request:
permissions: 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: jobs:
############################################################# #############################################################

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start 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 ```bash
# clone the repo # clone the repo
@@ -90,7 +90,7 @@ Requirements
------------ ------------
- [Node.js](https://nodejs.org) - [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. 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", "lodash",
"jwt-decode", "jwt-decode",
"uuid", "uuid",
"webfontloader",
"zone.js" "zone.js"
], ],
"outputPath": "dist/browser", "outputPath": "dist/browser",
@@ -59,7 +58,10 @@
"input": "src/themes/dspace/styles/theme.scss", "input": "src/themes/dspace/styles/theme.scss",
"inject": false, "inject": false,
"bundleName": "dspace-theme" "bundleName": "dspace-theme"
} },
"node_modules/leaflet/dist/leaflet.css",
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
"node_modules/leaflet.markercluster/dist/MarkerCluster.css"
], ],
"scripts": [], "scripts": [],
"baseHref": "/" "baseHref": "/"

View File

@@ -1,7 +1,7 @@
# NOTE: will log all redux actions and transfers in console # NOTE: will log all redux actions and transfers in console
debug: false debug: false
# Angular Universal server settings # Angular User Inteface settings
# NOTE: these settings define where Node.js will start your UI application. Therefore, these # 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" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar)
ui: ui:
@@ -17,12 +17,37 @@ ui:
# Trust X-FORWARDED-* headers from proxies (default = true) # Trust X-FORWARDED-* headers from proxies (default = true)
useProxies: true useProxies: true
universal: # Angular Server Side Rendering (SSR) settings
# Whether to inline "critical" styles into the server-side rendered HTML. ssr:
# Determining which styles are critical is a relatively expensive operation; # Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
# this option can be disabled to boost server performance at the expense of # Determining which styles are critical is a relatively expensive operation; this option is
# loading smoothness. # disabled (false) by default to boost server performance at the expense of loading smoothness.
inlineCriticalCss: true 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 # The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually # NOTE: these settings define which (publicly available) REST API to use. They are usually
@@ -33,6 +58,9 @@ rest:
port: 443 port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server nameSpace: /server
# 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 # Caching settings
cache: cache:
@@ -322,15 +350,25 @@ item:
# Rounded to the nearest size in the list of selectable sizes on the # Rounded to the nearest size in the list of selectable sizes on the
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
pageSize: 5 pageSize: 5
# Show the bitstream access status label on the item page
showAccessStatuses: false
# Community Page Config # Community Page Config
community: community:
# Default tab to be shown when browsing a Community. Valid values are: comcols, search, or browse_<field>
# <field> must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject
# When the default tab is not the 'search' tab, the search tab is moved to the last position
defaultBrowseTab: search
# Search tab config # Search tab config
searchSection: searchSection:
showSidebar: true showSidebar: true
# Collection Page Config # Collection Page Config
collection: collection:
# Default tab to be shown when browsing a Collection. Valid values are: search, or browse_<field>
# <field> must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject
# When the default tab is not the 'search' tab, the search tab is moved to the last position
defaultBrowseTab: search
# Search tab config # Search tab config
searchSection: searchSection:
showSidebar: true showSidebar: true
@@ -448,6 +486,12 @@ search:
enabled: false enabled: false
# List of filters to enable in "Advanced Search" dropdown # List of filters to enable in "Advanced Search" dropdown
filter: [ 'title', 'author', 'subject', 'entityType' ] 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 # Notify metrics
@@ -502,7 +546,6 @@ notifyMetrics:
config: 'NOTIFY.outgoing.delivered' config: 'NOTIFY.outgoing.delivered'
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
# Live Region configuration # Live Region configuration
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms: # 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 # Live regions are perceivable regions of a web page that are typically updated as a
@@ -516,3 +559,37 @@ liveRegion:
messageTimeOutDurationMs: 30000 messageTimeOutDurationMs: 30000
# The visibility of the live region. Setting this to true is only useful for debugging purposes. # The visibility of the live region. Setting this to true is only useful for debugging purposes.
isVisible: false isVisible: false
# Geospatial Map display options
geospatialMapViewer:
# Which fields to use for parsing as geospatial points in search maps
# (note, the item page field component allows any field(s) to be used
# and is set as an input when declaring the component)
spatialMetadataFields:
- 'dcterms.spatial'
# Which discovery configuration to use for 'geospatial search', used
# in the browse map
spatialFacetDiscoveryConfiguration: 'geospatial'
# Which filter / facet name to use for faceted geospatial search
# used in the browse map
spatialPointFilterName: 'point'
# Whether item page geospatial metadata should be displayed
# (assumes they are wrapped in a test for this config in the template as
# per the default templates supplied with DSpace for untyped-item and publication)
enableItemPageFields: false
# Whether the browse map should be enabled and included in the browse menu
enableBrowseMap: false
# Whether a 'map view' mode should be included alongside list and grid views
# in search result pages
enableSearchViewMode: false
# The tile provider(s) to use for the map tiles drawn in the leaflet maps.
# (see https://leaflet-extras.github.io/leaflet-providers/preview/) for a full list
tileProviders:
- 'OpenStreetMap.Mapnik'
# Starting centre point for the map, as lat and lng coordinates. This is useful
# to set the centre of the map when the map is first loaded and if there are no
# points, shapes or markers to display.
# Defaults to the centre of Istanbul
defaultCentrePoint:
lat: 41.015137
lng: 28.979530

View File

@@ -9,10 +9,12 @@ describe('Admin Add New Modals', () => {
it('Add new Community modal should pass accessibility tests', () => { it('Add new Community modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-new-title').click(); 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(); cy.get('a[data-test="menu.section.new_community"]').click();
@@ -22,10 +24,12 @@ describe('Admin Add New Modals', () => {
it('Add new Collection modal should pass accessibility tests', () => { it('Add new Collection modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-new-title').click(); 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(); cy.get('a[data-test="menu.section.new_collection"]').click();
@@ -35,10 +39,12 @@ describe('Admin Add New Modals', () => {
it('Add new Item modal should pass accessibility tests', () => { it('Add new Item modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-new-title').click(); 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(); cy.get('a[data-test="menu.section.new_item"]').click();

View File

@@ -9,10 +9,12 @@ describe('Admin Edit Modals', () => {
it('Edit Community modal should pass accessibility tests', () => { it('Edit Community modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-edit-title').click(); 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(); cy.get('a[data-test="menu.section.edit_community"]').click();
@@ -22,10 +24,12 @@ describe('Admin Edit Modals', () => {
it('Edit Collection modal should pass accessibility tests', () => { it('Edit Collection modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-edit-title').click(); 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(); cy.get('a[data-test="menu.section.edit_collection"]').click();
@@ -35,10 +39,12 @@ describe('Admin Edit Modals', () => {
it('Edit Item modal should pass accessibility tests', () => { it('Edit Item modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-edit-title').click(); 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(); cy.get('a[data-test="menu.section.edit_item"]').click();

View File

@@ -9,10 +9,12 @@ describe('Admin Export Modals', () => {
it('Export metadata modal should pass accessibility tests', () => { it('Export metadata modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-export-title').click(); 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(); cy.get('a[data-test="menu.section.export_metadata"]').click();
@@ -22,10 +24,12 @@ describe('Admin Export Modals', () => {
it('Export batch modal should pass accessibility tests', () => { it('Export batch modal should pass accessibility tests', () => {
// Pin the sidebar open // Pin the sidebar open
cy.get('#sidebar-collapse-toggle').click(); cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu // Click on entry of menu
cy.get('#admin-menu-section-export-title').click(); 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(); cy.get('a[data-test="menu.section.export_batch"]').click();

View File

@@ -1,5 +1,4 @@
import { testA11y } from 'cypress/support/utils'; import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Admin Sidebar', () => { describe('Admin Sidebar', () => {
beforeEach(() => { beforeEach(() => {
@@ -10,19 +9,12 @@ describe('Admin Sidebar', () => {
it('should be pinnable and pass accessibility tests', () => { it('should be pinnable and pass accessibility tests', () => {
// Pin the sidebar open // 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 // Click on every expandable section to open all menus
cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true }); cy.get('ds-expandable-admin-sidebar-section').click({ multiple: true });
// Analyze <ds-admin-sidebar> for accessibility // Analyze <ds-admin-sidebar> for accessibility
testA11y('ds-admin-sidebar', testA11y('ds-admin-sidebar');
{
rules: {
// Currently all expandable sections have nested interactive elements
// See https://github.com/DSpace/dspace-angular/issues/2178
'nested-interactive': { enabled: false },
},
} as Options);
}); });
}); });

View File

@@ -12,6 +12,13 @@ describe('Community List Page', () => {
cy.get('[data-test="expand-button"]').click({ multiple: true }); cy.get('[data-test="expand-button"]').click({ multiple: true });
// Analyze <ds-community-list-page> for accessibility issues // Analyze <ds-community-list-page> for accessibility issues
testA11y('ds-community-list-page'); testA11y('ds-community-list-page', {
rules: {
// When expanding a cdk node on the community-list page, the 'aria-posinset' property becomes 0.
// 0 is not a valid value for 'aria-posinset' so the test fails.
// see https://github.com/DSpace/dspace-angular/issues/4068
'aria-valid-attr-value': { enabled: false },
},
});
}); });
}); });

View File

@@ -9,18 +9,15 @@ beforeEach(() => {
// This page is restricted, so we will be shown the login form. Fill it out & submit. // This page is restricted, so we will be shown the login form. Fill it out & submit.
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
// We need to wait for the correction types allowed for the item to be loaded to be sure that each tab is fully loaded.
// This because the edit item page causes often tests to fails due to timeout.
cy.intercept('GET', 'server/api/config/correctiontypes/search/findByItem*').as('correctionTypes');
cy.wait('@correctionTypes');
}); });
describe('Edit Item > Edit Metadata tab', () => { describe('Edit Item > Edit Metadata tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="metadata"]').should('be.visible');
cy.get('a[data-test="metadata"]').click(); cy.get('a[data-test="metadata"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="metadata"]').should('have.class', 'active');
// <ds-edit-item-page> tag must be loaded // <ds-edit-item-page> tag must be loaded
@@ -39,9 +36,11 @@ describe('Edit Item > Edit Metadata tab', () => {
describe('Edit Item > Status tab', () => { describe('Edit Item > Status tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="status"]').should('be.visible');
cy.get('a[data-test="status"]').click(); cy.get('a[data-test="status"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="status"]').should('have.class', 'active');
// <ds-item-status> tag must be loaded // <ds-item-status> tag must be loaded
@@ -55,9 +54,11 @@ describe('Edit Item > Status tab', () => {
describe('Edit Item > Bitstreams tab', () => { describe('Edit Item > Bitstreams tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="bitstreams"]').should('be.visible');
cy.get('a[data-test="bitstreams"]').click(); cy.get('a[data-test="bitstreams"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="bitstreams"]').should('have.class', 'active');
// <ds-item-bitstreams> tag must be loaded // <ds-item-bitstreams> tag must be loaded
@@ -82,9 +83,11 @@ describe('Edit Item > Bitstreams tab', () => {
describe('Edit Item > Curate tab', () => { describe('Edit Item > Curate tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="curate"]').should('be.visible');
cy.get('a[data-test="curate"]').click(); cy.get('a[data-test="curate"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="curate"]').should('have.class', 'active');
// <ds-item-curate> tag must be loaded // <ds-item-curate> tag must be loaded
@@ -98,9 +101,11 @@ describe('Edit Item > Curate tab', () => {
describe('Edit Item > Relationships tab', () => { describe('Edit Item > Relationships tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="relationships"]').should('be.visible');
cy.get('a[data-test="relationships"]').click(); cy.get('a[data-test="relationships"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="relationships"]').should('have.class', 'active');
// <ds-item-relationships> tag must be loaded // <ds-item-relationships> tag must be loaded
@@ -114,9 +119,11 @@ describe('Edit Item > Relationships tab', () => {
describe('Edit Item > Version History tab', () => { describe('Edit Item > Version History tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="versionhistory"]').should('be.visible');
cy.get('a[data-test="versionhistory"]').click(); cy.get('a[data-test="versionhistory"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="versionhistory"]').should('have.class', 'active');
// <ds-item-version-history> tag must be loaded // <ds-item-version-history> tag must be loaded
@@ -130,9 +137,11 @@ describe('Edit Item > Version History tab', () => {
describe('Edit Item > Access Control tab', () => { describe('Edit Item > Access Control tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="access-control"]').should('be.visible');
cy.get('a[data-test="access-control"]').click(); cy.get('a[data-test="access-control"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="access-control"]').should('have.class', 'active');
// <ds-item-access-control> tag must be loaded // <ds-item-access-control> tag must be loaded
@@ -146,9 +155,11 @@ describe('Edit Item > Access Control tab', () => {
describe('Edit Item > Collection Mapper tab', () => { describe('Edit Item > Collection Mapper tab', () => {
it('should pass accessibility tests', () => { it('should pass accessibility tests', () => {
cy.get('a[data-test="mapper"]').should('be.visible');
cy.get('a[data-test="mapper"]').click(); cy.get('a[data-test="mapper"]').click();
// Our selected tab should be active // 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'); cy.get('a[data-test="mapper"]').should('have.class', 'active');
// <ds-item-collection-mapper> tag must be loaded // <ds-item-collection-mapper> tag must be loaded

View File

@@ -7,7 +7,7 @@ describe('Item Statistics Page', () => {
it('should load if you click on "Statistics" from an Item/Entity page', () => { it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); cy.location('pathname').should('eq', '/statistics/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
}); });
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {

View File

@@ -217,7 +217,7 @@ describe('New Submission page', () => {
}); });
// Close popup window // Close popup window
cy.get('ds-dynamic-lookup-relation-modal button.close').click(); cy.get('ds-dynamic-lookup-relation-modal button.btn-close').click();
// Back on the form, click the discard button to remove new submission // Back on the form, click the discard button to remove new submission
// Clicking it will display a confirmation, which we will confirm with another click // Clicking it will display a confirmation, which we will confirm with another click

View File

@@ -54,9 +54,9 @@ before(() => {
// Runs once before the first test in each "block" // Runs once before the first test in each "block"
beforeEach(() => { beforeEach(() => {
// Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // Pre-agree to all 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. // 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 // Remove any CSRF cookies saved from prior tests
cy.clearCookie(DSPACE_XSRF_COOKIE); cy.clearCookie(DSPACE_XSRF_COOKIE);

View File

@@ -1,10 +1,16 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"include": [ "include": [
"**/*.ts" "**/*.ts",
"../cypress.config.ts"
], ],
"compilerOptions": { "compilerOptions": {
"sourceMap": false, "sourceMap": false,
"typeRoots": [
"../node_modules",
"../node_modules/@types",
"../src/typings.d.ts"
],
"types": [ "types": [
"cypress", "cypress",
"cypress-axe", "cypress-axe",

View File

@@ -21,7 +21,7 @@ networks:
external: true external: true
services: services:
dspace-cli: 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 container_name: dspace-cli
environment: environment:
# Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables.

View File

@@ -14,7 +14,7 @@
# # Therefore, it should be kept in sync with that file # # Therefore, it should be kept in sync with that file
services: services:
dspacedb: 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: environment:
# This LOADSQL should be kept in sync with the URL in DSpace/DSpace # 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 # 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. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
solr__D__statistics__P__autoCommit: 'false' solr__D__statistics__P__autoCommit: 'false'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml 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: depends_on:
- dspacedb - dspacedb
networks: 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 # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data
dspacedb: dspacedb:
container_name: 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: environment:
# This LOADSQL should be kept in sync with the LOADSQL in # 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 # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
@@ -81,7 +81,7 @@ services:
# DSpace Solr container # DSpace Solr container
dspacesolr: dspacesolr:
container_name: dspacesolr container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
networks: networks:
- dspacenet - dspacenet
ports: ports:
@@ -93,7 +93,10 @@ services:
volumes: volumes:
# Keep Solr data directory between reboots # Keep Solr data directory between reboots
- solr_data:/var/solr/data - solr_data:/var/solr/data
# Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr # NOTE: We are not running Solr as "root", but we need root permissions to copy our cores to the mounted
# /var/solr/data directory. Then we start Solr as the "solr" user.
user: root
# Initialize all DSpace Solr cores, then start Solr
entrypoint: entrypoint:
- /bin/bash - /bin/bash
- '-c' - '-c'
@@ -111,7 +114,8 @@ services:
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
precreate-core suggestion /opt/solr/server/solr/configsets/suggestion precreate-core suggestion /opt/solr/server/solr/configsets/suggestion
cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion
exec solr -f chown -R solr:solr /var/solr
runuser -u solr -- solr-foreground
volumes: volumes:
assetstore: assetstore:
pgdata: pgdata:

View File

@@ -26,7 +26,7 @@ services:
DSPACE_REST_HOST: sandbox.dspace.org DSPACE_REST_HOST: sandbox.dspace.org
DSPACE_REST_PORT: 443 DSPACE_REST_PORT: 443
DSPACE_REST_NAMESPACE: /server DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-latest}-dist"
build: build:
context: .. context: ..
dockerfile: Dockerfile.dist 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. # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0' proxies__P__trusted__P__ipranges: '172.23.0'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml 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: depends_on:
- dspacedb - dspacedb
networks: networks:
@@ -68,7 +68,7 @@ services:
dspacedb: dspacedb:
container_name: dspacedb container_name: dspacedb
# Uses a custom Postgres image with pgcrypto installed # 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: environment:
PGDATA: /pgdata PGDATA: /pgdata
POSTGRES_PASSWORD: dspace POSTGRES_PASSWORD: dspace
@@ -85,7 +85,7 @@ services:
# DSpace Solr container # DSpace Solr container
dspacesolr: dspacesolr:
container_name: dspacesolr container_name: dspacesolr
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}"
networks: networks:
- dspacenet - dspacenet
ports: ports:
@@ -97,11 +97,16 @@ services:
volumes: volumes:
# Keep Solr data directory between reboots # Keep Solr data directory between reboots
- solr_data:/var/solr/data - solr_data:/var/solr/data
# NOTE: We are not running Solr as "root", but we need root permissions to copy our cores to the mounted
# /var/solr/data directory. Then we start Solr as the "solr" user.
user: root
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr # Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op # * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
# * Second, copy configsets to this core: # * Second, copy configsets to this core:
# Updates to Solr configs require the container to be rebuilt/restarted: # Updates to Solr configs require the container to be rebuilt/restarted:
# `docker compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr` # `docker compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr`
# * Third, ensure all new folders are owned by "solr" user
# * Finally, start Solr as the "solr" user via the provided solr-foreground script
entrypoint: entrypoint:
- /bin/bash - /bin/bash
- '-c' - '-c'
@@ -119,7 +124,8 @@ services:
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
precreate-core suggestion /opt/solr/server/solr/configsets/suggestion precreate-core suggestion /opt/solr/server/solr/configsets/suggestion
cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion
exec solr -f chown -R solr:solr /var/solr
runuser -u solr -- solr-foreground
volumes: volumes:
assetstore: assetstore:
pgdata: pgdata:

View File

@@ -23,7 +23,7 @@ services:
DSPACE_REST_HOST: localhost DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: /server DSPACE_REST_NAMESPACE: /server
image: dspace/dspace-angular:${DSPACE_VER:-latest} image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-latest}"
build: build:
context: .. context: ..
dockerfile: Dockerfile 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/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, bundle,
RuleExports, RuleExports,
} from '../../util/structure'; } from '../../util/structure';
import * as noDisabledAttributeOnButton from './no-disabled-attribute-on-button';
import * as themedComponentUsages from './themed-component-usages'; import * as themedComponentUsages from './themed-component-usages';
const index = [ const index = [
themedComponentUsages, themedComponentUsages,
noDisabledAttributeOnButton,
] as unknown as RuleExports[]; ] as unknown as RuleExports[];
export = { 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;

13929
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,153 +57,150 @@
"private": true, "private": true,
"overrides": { "overrides": {
"@kolkov/ngx-gallery": { "@kolkov/ngx-gallery": {
"@angular/animations": "^17.3.11", "@angular/animations": "^18.2.12",
"@angular/common": "^17.3.11", "@angular/common": "^18.2.12",
"@angular/core": "^17.3.11" "@angular/core": "^18.2.12"
}, },
"@ng-bootstrap/ng-bootstrap": { "@ng-bootstrap/ng-bootstrap": {
"@angular/common": "^17.3.11", "@angular/common": "^18.2.12",
"@angular/core": "^17.3.11", "@angular/core": "^18.2.12",
"@angular/forms": "^17.3.11", "@angular/forms": "^18.2.12",
"@angular/localize": "^17.3.11" "@angular/localize": "^18.2.12"
}, },
"@ng-dynamic-forms/core": { "@ng-dynamic-forms/core": {
"@angular/common": "^17.3.11", "@angular/common": "^18.2.12",
"@angular/core": "^17.3.11", "@angular/core": "^18.2.12",
"@angular/forms": "^17.3.11" "@angular/forms": "^18.2.12"
}, },
"@ng-dynamic-forms/ui-ng-bootstrap": { "@ng-dynamic-forms/ui-ng-bootstrap": {
"ngx-mask": "14.2.4" "ngx-mask": "14.2.4",
}, "@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ngtools/webpack": { "bootstrap": "^5.3"
"@angular/compiler-cli": "^17.3.11",
"typescript": "~5.3.3"
}, },
"@nicky-lenaers/ngx-scroll-to": { "@nicky-lenaers/ngx-scroll-to": {
"@angular/common": "^17.3.11", "@angular/common": "^18.2.12",
"@angular/core": "^17.3.11" "@angular/core": "^18.2.12"
}, },
"eslint-plugin-unused-imports": { "eslint-plugin-unused-imports": {
"@typescript-eslint/eslint-plugin": "^7.2.0" "@typescript-eslint/eslint-plugin": "^7.2.0"
}, },
"ng2-file-upload": {
"@angular/common": "^17.3.11",
"@angular/core": "^17.3.11"
},
"ngx-infinite-scroll": { "ngx-infinite-scroll": {
"@angular/common": "^17.3.11", "@angular/common": "^18.2.12",
"@angular/core": "^17.3.11" "@angular/core": "^18.2.12"
} },
"notistack": "3.0.1"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "^17.3.12", "@angular/animations": "^18.2.12",
"@angular/cdk": "^17.3.10", "@angular/cdk": "^18.2.12",
"@angular/common": "^17.3.12", "@angular/common": "^18.2.12",
"@angular/compiler": "^17.3.12", "@angular/compiler": "^18.2.12",
"@angular/core": "^17.3.12", "@angular/core": "^18.2.12",
"@angular/forms": "^17.3.12", "@angular/forms": "^18.2.12",
"@angular/localize": "^17.3.12", "@angular/localize": "^18.2.12",
"@angular/platform-browser": "^17.3.12", "@angular/platform-browser": "^18.2.12",
"@angular/platform-browser-dynamic": "^17.3.12", "@angular/platform-browser-dynamic": "^18.2.12",
"@angular/platform-server": "^17.3.12", "@angular/platform-server": "^18.2.12",
"@angular/router": "^17.3.12", "@angular/router": "^18.2.12",
"@angular/ssr": "^17.3.10", "@angular/ssr": "^18.2.18",
"@babel/runtime": "7.25.7", "@babel/runtime": "7.27.0",
"@kolkov/ngx-gallery": "^2.0.1", "@kolkov/ngx-gallery": "^2.0.1",
"@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ng-dynamic-forms/core": "^16.0.0", "@ng-dynamic-forms/core": "^16.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0", "@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0",
"@ngrx/effects": "^17.1.1", "@ngrx/effects": "^18.1.1",
"@ngrx/router-store": "^17.1.1", "@ngrx/operators": "^18.0.0",
"@ngrx/store": "^17.1.1", "@ngrx/router-store": "^18.1.1",
"@ngx-translate/core": "^14.0.0", "@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4", "@terraformer/wkt": "^2.2.1",
"angular-idle-preload": "3.0.0", "altcha": "^0.9.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.4", "axios": "^1.8.4",
"bootstrap": "^4.6.1", "bootstrap": "^5.3",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"colors": "^1.4.0", "colors": "^1.4.0",
"compression": "^1.7.4", "compression": "^1.7.5",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"core-js": "^3.38.1", "core-js": "^3.41.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7", "date-fns-tz": "^1.3.7",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.2",
"express-rate-limit": "^5.1.3", "express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"filesize": "^6.1.0", "filesize": "^10.1.6",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^2.0.9",
"http-terminator": "^3.2.0", "http-terminator": "^3.2.0",
"isbot": "^5.1.17", "isbot": "^5.1.26",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"jsonschema": "1.4.1", "jsonschema": "1.5.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"klaro": "^0.7.18", "leaflet": "^1.9.4",
"lodash": "^4.17.21", "leaflet-providers": "^2.0.0",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.17.21",
"lru-cache": "^7.14.1", "lru-cache": "^7.14.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"mirador": "^3.3.0", "mirador": "^3.4.3",
"mirador-dl-plugin": "^0.13.0", "mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0", "mirador-share-plugin": "^0.16.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ng2-file-upload": "5.0.0", "ng2-file-upload": "7.0.1",
"ng2-nouislider": "^2.0.0", "ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^16.0.0", "ngx-infinite-scroll": "^18.0.0",
"ngx-matomo-client": "^6.4.1",
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-ui-switch": "^14.1.0", "ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^15.0.0",
"nouislider": "^15.7.1", "nouislider": "^15.7.1",
"orejime": "^2.3.1",
"pem": "1.14.8", "pem": "1.14.8",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sanitize-html": "^2.12.1",
"sortablejs": "1.15.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webfontloader": "1.6.28", "zone.js": "~0.14.10"
"zone.js": "~0.14.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "~17.0.2", "@angular-builders/custom-webpack": "~18.0.0",
"@angular-devkit/build-angular": "^17.3.8", "@angular-devkit/build-angular": "^18.2.18",
"@angular-eslint/builder": "^17.5.3", "@angular-eslint/builder": "^18.4.1",
"@angular-eslint/bundled-angular-compiler": "^17.5.3", "@angular-eslint/bundled-angular-compiler": "^18.4.1",
"@angular-eslint/eslint-plugin": "^17.5.3", "@angular-eslint/eslint-plugin": "^18.4.1",
"@angular-eslint/eslint-plugin-template": "^17.5.3", "@angular-eslint/eslint-plugin-template": "^18.4.1",
"@angular-eslint/schematics": "^17.5.3", "@angular-eslint/schematics": "^18.4.1",
"@angular-eslint/template-parser": "^17.5.3", "@angular-eslint/template-parser": "^18.4.1",
"@angular-eslint/utils": "^17.5.3", "@angular-eslint/utils": "^18.4.1",
"@angular/cli": "^17.3.10", "@angular/cli": "^18.2.18",
"@angular/compiler-cli": "^17.3.11", "@angular/compiler-cli": "^18.2.12",
"@angular/language-service": "^17.3.12", "@angular/language-service": "^18.2.12",
"@cypress/schematic": "^1.5.0", "@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@ngrx/store-devtools": "^17.1.1", "@ngrx/store-devtools": "^18.1.1",
"@ngtools/webpack": "^16.2.16", "@ngtools/webpack": "^18.2.18",
"@types/deep-freeze": "0.1.5", "@types/deep-freeze": "0.1.5",
"@types/ejs": "^3.1.2", "@types/ejs": "^3.1.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/grecaptcha": "^3.0.9",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",
"@types/lodash": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^14.14.9", "@types/node": "^14.14.9",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/rule-tester": "^7.18.0", "@typescript-eslint/rule-tester": "^7.18.0",
"@typescript-eslint/utils": "^7.18.0", "@typescript-eslint/utils": "^7.18.0",
"axe-core": "^4.10.0", "axe-core": "^4.10.2",
"browser-sync": "^3.0.3",
"compression-webpack-plugin": "^9.2.0", "compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "^13.15.0", "cypress": "^13.17.0",
"cypress-axe": "^1.5.0", "cypress-axe": "^1.6.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"eslint": "^8.39.0", "eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.4.1", "eslint-plugin-deprecation": "^1.4.1",
@@ -212,12 +209,12 @@
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0", "eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-jsonc": "^2.20.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"express-static-gzip": "^2.1.8", "express-static-gzip": "^2.2.0",
"jasmine": "^3.8.0", "jasmine": "^3.8.0",
"jasmine-core": "^3.8.0", "jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2", "jasmine-marbles": "0.9.2",
@@ -227,26 +224,20 @@
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"ng-mocks": "^14.13.1", "ng-mocks": "^14.13.4",
"ngx-mask": "14.2.4", "ngx-mask": "14.2.4",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"postcss": "^8.4", "postcss": "^8.5",
"postcss-apply": "0.12.0",
"postcss-import": "^14.0.0", "postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3", "postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2", "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", "rimraf": "^3.0.2",
"rxjs-spy": "^8.0.2", "sass": "~1.86.3",
"sass": "~1.80.3",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"sass-resources-loader": "^2.2.5", "sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"typescript": "~5.3.3", "typescript": "~5.4.5",
"webpack": "5.94.0", "webpack": "5.99.6",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1" "webpack-dev-server": "^4.15.1"
} }

View File

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

View File

@@ -20,10 +20,10 @@ import 'reflect-metadata';
/* eslint-disable import/no-namespace */ /* eslint-disable import/no-namespace */
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as express from 'express'; import express from 'express';
import * as ejs from 'ejs'; import * as ejs from 'ejs';
import * as compression from 'compression'; import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip'; import expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */ /* eslint-enable import/no-namespace */
import axios from 'axios'; import axios from 'axios';
import LRU from 'lru-cache'; import LRU from 'lru-cache';
@@ -81,6 +81,9 @@ let anonymousCache: LRU<string, any>;
// extend environment with app config for server // extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig); extendEnvironmentWithAppConfig(environment, appConfig);
// 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. // The Express app is exported so that it can be used by serverless Functions.
export function app() { export function app() {
@@ -156,7 +159,7 @@ export function app() {
* Proxy the sitemaps * Proxy the sitemaps
*/ */
router.use('/sitemap**', createProxyMiddleware({ router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`, target: `${REST_BASE_URL}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true, changeOrigin: true,
})); }));
@@ -165,7 +168,7 @@ export function app() {
* Proxy the linksets * Proxy the linksets
*/ */
router.use('/signposting**', createProxyMiddleware({ router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`, target: `${REST_BASE_URL}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true, changeOrigin: true,
})); }));
@@ -218,7 +221,7 @@ export function app() {
* The callback function to serve server side angular * The callback function to serve server side angular
*/ */
function ngApp(req, res, next) { 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) // Render the page to user via SSR (server side rendering)
serverSideRender(req, res, next); serverSideRender(req, res, next);
} else { } else {
@@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) {
}) })
.then((html) => { .then((html) => {
if (hasValue(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) // save server side rendered page to cache (if any are enabled)
saveToCache(req, html); saveToCache(req, html);
if (sendToUser) { if (sendToUser) {
@@ -623,7 +631,7 @@ function start() {
* The callback function to serve health check requests * The callback function to serve health check requests
*/ */
function healthCheck(req, res) { function healthCheck(req, res) {
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
axios.get(baseUrl) axios.get(baseUrl)
.then((response) => { .then((response) => {
res.status(response.status).send(response.data); res.status(response.status).send(response.data);

View File

@@ -1,19 +1,13 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'"> <ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
<ngb-panel [id]="'browse'"> <ngb-panel [id]="'browse'">
<ng-template ngbPanelHeader> <ng-template ngbPanelTitle>
<div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')" <div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" (click)="acc.toggle('browse')"
data-test="browse"> data-test="browse">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()" <button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
[attr.aria-expanded]="acc.isExpanded('browse')" [attr.aria-expanded]="acc.isExpanded('browse')"
aria-controls="bulk-access-browse-panel-content"> aria-controls="bulk-access-browse-panel-content">
{{ 'admin.access-control.bulk-access-browse.header' | translate }} {{ 'admin.access-control.bulk-access-browse.header' | translate }}
</button> </button>
<div class="text-right d-flex gap-2">
<div class="d-flex my-auto">
<span *ngIf="acc.isExpanded('browse')" class="fas fa-chevron-up fa-fw"></span>
<span *ngIf="!acc.isExpanded('browse')" class="fas fa-chevron-down fa-fw"></span>
</div>
</div>
</div> </div>
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
@@ -22,11 +16,11 @@
<li [ngbNavItem]="'search'" role="presentation"> <li [ngbNavItem]="'search'" role="presentation">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a> <a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mx-n3"> <div class="bulk-access-search">
<ds-search [configuration]="'administrativeBulkAccess'" <ds-search [configuration]="'administrativeBulkAccess'"
[selectable]="true" [selectable]="true"
[selectionConfig]="{ repeatable: true, listId: listId }" [selectionConfig]="{ repeatable: true, listId: listId }"
[showThumbnails]="false"></ds-search> [showThumbnails]="false"></ds-search>
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -42,21 +36,25 @@
[showPaginator]="false" [showPaginator]="false"
(prev)="pagePrev()" (prev)="pagePrev()"
(next)="pageNext()"> (next)="pageNext()">
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4"> @if ((objectsSelected$|async)?.hasSucceeded) {
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize, <ul class="list-unstyled ms-4">
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last ' @for (object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
class="mt-4 mb-4 d-flex" currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; track object; let i = $index; let last = $last) {
[attr.data-test]="'list-object' | dsBrowserOnly"> <li
<ds-selectable-list-item-control [index]="i" class="mt-4 mb-4 d-flex"
[object]="object" [attr.data-test]="'list-object' | dsBrowserOnly">
[selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control> <ds-selectable-list-item-control [index]="i"
<ds-listable-object-component-loader [listID]="listId" [object]="object"
[index]="i" [selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control>
[object]="object" <ds-listable-object-component-loader [listID]="listId"
[showThumbnails]="false" [index]="i"
[viewMode]="'list'"></ds-listable-object-component-loader> [object]="object"
</li> [showThumbnails]="false"
</ul> [viewMode]="'list'"></ds-listable-object-component-loader>
</li>
}
</ul>
}
</ds-pagination> </ds-pagination>
</ng-template> </ng-template>
</li> </li>

View File

@@ -0,0 +1,4 @@
.bulk-access-search {
margin-right: calc(var(--bs-gutter-x, 1.5rem) / -2);
margin-left: calc(var(--bs-gutter-x, 1.5rem) / -2);
}

View File

@@ -1,8 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
Input, Input,
@@ -59,11 +55,9 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
AsyncPipe, AsyncPipe,
NgbAccordionModule, NgbAccordionModule,
TranslateModule, TranslateModule,
NgIf,
NgbNavModule, NgbNavModule,
ThemedSearchComponent, ThemedSearchComponent,
BrowserOnlyPipe, BrowserOnlyPipe,
NgForOf,
NgxPaginationModule, NgxPaginationModule,
SelectableListItemControlComponent, SelectableListItemControlComponent,
ListableObjectComponentLoaderComponent, ListableObjectComponentLoaderComponent,

View File

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

View File

@@ -1,4 +1,7 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'; import {
Component,
NO_ERRORS_SCHEMA,
} from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
TestBed, TestBed,
@@ -62,10 +65,17 @@ describe('BulkAccessComponent', () => {
'file': { }, 'file': { },
}; };
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { @Component({
getValue: jasmine.createSpy('getValue'), selector: 'ds-bulk-access-settings',
reset: jasmine.createSpy('reset'), template: '',
}); exportAs: 'dsBulkSettings',
standalone: true,
})
class MockBulkAccessSettingsComponent {
isFormValid = jasmine.createSpy('isFormValid').and.returnValue(false);
getValue = jasmine.createSpy('getValue');
reset = jasmine.createSpy('reset');
}
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
const selectableListState: SelectableListState = { id: 'test', selection }; const selectableListState: SelectableListState = { id: 'test', selection };
const expectedIdList = ['1234', '5678']; const expectedIdList = ['1234', '5678'];
@@ -93,6 +103,9 @@ describe('BulkAccessComponent', () => {
BulkAccessSettingsComponent, BulkAccessSettingsComponent,
], ],
}, },
add: {
imports: [MockBulkAccessSettingsComponent],
},
}) })
.compileComponents(); .compileComponents();
}); });
@@ -109,13 +122,12 @@ describe('BulkAccessComponent', () => {
fixture.destroy(); fixture.destroy();
}); });
describe('when there are no elements selected', () => { describe('when there are no elements selected and step two form is invalid', () => {
beforeEach(() => { beforeEach(() => {
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty)); (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty));
fixture.detectChanges(); fixture.detectChanges();
component.settings = mockSettings;
}); });
it('should create', () => { it('should create', () => {
@@ -138,7 +150,6 @@ describe('BulkAccessComponent', () => {
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState)); (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
fixture.detectChanges(); fixture.detectChanges();
component.settings = mockSettings;
}); });
it('should create', () => { it('should create', () => {
@@ -149,15 +160,29 @@ describe('BulkAccessComponent', () => {
expect(component.objectsSelected$.value).toEqual(expectedIdList); expect(component.objectsSelected$.value).toEqual(expectedIdList);
}); });
it('should enable the execute button when there are objects selected', () => { it('should not enable the execute button when there are objects selected and step two form is invalid', () => {
component.objectsSelected$.next(['1234']); component.objectsSelected$.next(['1234']);
expect(component.canExport()).toBe(true); expect(component.canExport()).toBe(false);
}); });
it('should call the settings reset method when reset is called', () => { it('should call the settings reset method when reset is called', () => {
component.reset(); component.reset();
expect(component.settings.reset).toHaveBeenCalled(); expect(component.settings.reset).toHaveBeenCalled();
}); });
});
describe('when there are elements selected and the step two form is valid', () => {
beforeEach(() => {
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
fixture.detectChanges();
(component as any).settings.isFormValid.and.returnValue(true);
});
it('should enable the execute button when there are objects selected and step two form is valid', () => {
component.objectsSelected$.next(['1234']);
expect(component.canExport()).toBe(true);
});
it('should call the bulkAccessControlService executeScript method when submit is called', () => { it('should call the bulkAccessControlService executeScript method when submit is called', () => {
(component.settings as any).getValue.and.returnValue(mockFormState); (component.settings as any).getValue.and.returnValue(mockFormState);

View File

@@ -1,4 +1,5 @@
import { import {
ChangeDetectionStrategy,
Component, Component,
OnInit, OnInit,
ViewChild, ViewChild,
@@ -14,6 +15,7 @@ import {
} from 'rxjs/operators'; } from 'rxjs/operators';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component'; import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component';
@@ -27,8 +29,10 @@ import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.com
TranslateModule, TranslateModule,
BulkAccessSettingsComponent, BulkAccessSettingsComponent,
BulkAccessBrowseComponent, BulkAccessBrowseComponent,
BtnDisabledDirective,
], ],
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BulkAccessComponent implements OnInit { export class BulkAccessComponent implements OnInit {
@@ -68,7 +72,7 @@ export class BulkAccessComponent implements OnInit {
} }
canExport(): boolean { canExport(): boolean {
return this.objectsSelected$.value?.length > 0; return this.objectsSelected$.value?.length > 0 && this.settings?.isFormValid();
} }
/** /**

View File

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

View File

@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import { import {
Component, Component,
ViewChild, ViewChild,
@@ -16,7 +16,6 @@ import { AccessControlFormContainerComponent } from '../../../shared/access-cont
imports: [ imports: [
NgbAccordionModule, NgbAccordionModule,
TranslateModule, TranslateModule,
NgIf,
AccessControlFormContainerComponent, AccessControlFormContainerComponent,
], ],
standalone: true, standalone: true,
@@ -43,4 +42,8 @@ export class BulkAccessSettingsComponent {
this.controlForm.reset(); this.controlForm.reset();
} }
isFormValid() {
return this.controlForm.isValid();
}
} }

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { import {
AsyncPipe, AsyncPipe,
NgClass, NgClass,
NgForOf,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
Component, Component,
@@ -72,13 +70,11 @@ import { EPersonFormComponent } from './eperson-form/eperson-form.component';
TranslateModule, TranslateModule,
RouterModule, RouterModule,
AsyncPipe, AsyncPipe,
NgIf,
EPersonFormComponent, EPersonFormComponent,
ReactiveFormsModule, ReactiveFormsModule,
ThemedLoadingComponent, ThemedLoadingComponent,
PaginationComponent, PaginationComponent,
NgClass, NgClass,
NgForOf,
], ],
standalone: true, standalone: true,
}) })
@@ -100,6 +96,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
ePeopleDto$: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>({} as any); 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 * An observable for the pageInfo, needed to pass to the pagination component
*/ */
@@ -165,6 +163,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
initialisePage() { initialisePage() {
this.searching$.next(true); this.searching$.next(true);
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
this.activeEPerson$ = this.epersonService.getActiveEPerson();
this.subs.push(this.ePeople$.pipe( this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => { switchMap((epeople: PaginatedList<EPerson>) => {
if (epeople.pageInfo.totalElements > 0) { if (epeople.pageInfo.totalElements > 0) {
@@ -232,23 +231,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 * Deletes EPerson, show notification on success/failure & updates EPeople list
*/ */

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { import {
AsyncPipe, AsyncPipe,
NgClass, NgClass,
NgFor,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
@@ -65,6 +63,7 @@ import {
import { PageInfo } from '../../../core/shared/page-info.model'; import { PageInfo } from '../../../core/shared/page-info.model';
import { Registration } from '../../../core/shared/registration.model'; import { Registration } from '../../../core/shared/registration.model';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -83,8 +82,6 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator';
templateUrl: './eperson-form.component.html', templateUrl: './eperson-form.component.html',
imports: [ imports: [
FormComponent, FormComponent,
NgIf,
NgFor,
AsyncPipe, AsyncPipe,
TranslateModule, TranslateModule,
NgClass, NgClass,
@@ -92,6 +89,7 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator';
PaginationComponent, PaginationComponent,
RouterLink, RouterLink,
HasNoValuePipe, HasNoValuePipe,
BtnDisabledDirective,
], ],
standalone: true, standalone: true,
}) })
@@ -189,6 +187,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
canImpersonate$: Observable<boolean>; canImpersonate$: Observable<boolean>;
/**
* The current {@link EPerson}
*/
activeEPerson$: Observable<EPerson>;
/** /**
* List of subscriptions * List of subscriptions
*/ */
@@ -254,7 +257,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected router: Router, 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; this.epersonInitial = eperson;
if (hasValue(eperson)) { if (hasValue(eperson)) {
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
@@ -262,9 +269,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.submitLabel = 'form.submit'; this.submitLabel = 'form.submit';
} }
})); }));
}
ngOnInit() {
this.initialisePage(); this.initialisePage();
} }
@@ -272,130 +276,121 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page * This method will initialise the page
*/ */
initialisePage() { initialisePage() {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => { if (this.route.snapshot.params.id) {
this.epersonService.editEPerson(ePersonRD.payload); 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();
});
}
})); }));
}
const activeEPerson$ = this.epersonService.getActiveEPerson(); this.firstName = new DynamicInputModel({
id: 'firstName',
this.groups$ = activeEPerson$.pipe( label: this.translateService.instant(`${this.messagePrefix}.firstName`),
switchMap((eperson) => { name: 'firstName',
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { validators: {
currentPage: 1, required: null,
elementsPerPage: this.config.pageSize, },
})]); required: true,
}),
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.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 +409,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Emit the updated/created eperson using the EventEmitter submitForm * Emit the updated/created eperson using the EventEmitter submitForm
*/ */
onSubmit() { onSubmit() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( this.activeEPerson$.pipe(take(1)).subscribe(
(ePerson: EPerson) => { (ePerson: EPerson) => {
const values = { const values = {
metadata: { metadata: {
@@ -533,7 +528,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. * It'll either show a success or error message depending on whether the delete was successful or not.
*/ */
delete(): void { delete(): void {
this.epersonService.getActiveEPerson().pipe( this.activeEPerson$.pipe(
take(1), take(1),
switchMap((eperson: EPerson) => { switchMap((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
@@ -637,7 +632,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* Update the list of groups by fetching it from the rest api or cache * Update the list of groups by fetching it from the rest api or cache
*/ */
private updateGroups(options) { 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); this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options);
})); }));
} }

View File

@@ -2,13 +2,7 @@
<div class="group-form row"> <div class="group-form row">
<div class="col-12"> <div class="col-12">
<div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div> @if (activeGroup$ | async) {
<ng-template #createHeader>
<h1 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h1>
</ng-template>
<ng-template #editHeader>
<h1 class="border-bottom pb-2"> <h1 class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
@@ -17,44 +11,61 @@
iconPlacement: 'right', iconPlacement: 'right',
tooltipPlacement: ['right', 'bottom'] tooltipPlacement: ['right', 'bottom']
}" }"
> >
{{messagePrefix + '.head.edit' | translate}} {{messagePrefix + '.head.edit' | translate}}
</span> </span>
</h1> </h1>
</ng-template> } @else {
<h1 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h1>
}
<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" @if ((activeGroup$ | async); as groupBeingEdited) {
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName((getLinkedDSO(groupBeingEdited) | async)?.payload), comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })"> @if (groupBeingEdited?.permanent) {
</ds-alert> <ds-alert [type]="AlertType.Warning"
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
}
@if ((activeGroupLinkedDSO$ | async); as activeGroupLinkedDSO) {
@if ((canEdit$ | async) !== true) {
<ds-alert [type]="AlertType.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName(activeGroupLinkedDSO), comcol: activeGroupLinkedDSO.type, comcolEditRolesRoute: (linkedEditRolesRoute$ | async) })">
</ds-alert>
}
}
}
<ds-form [formId]="formId" <ds-form [formId]="formId"
[formModel]="formModel" [formModel]="formModel"
[formGroup]="formGroup" [formGroup]="formGroup"
[formLayout]="formLayout" [formLayout]="formLayout"
[displayCancel]="false" [displayCancel]="false"
(submitForm)="onSubmit()"> (submitForm)="onSubmit()">
<div before class="btn-group"> <div before class="btn-group">
<button (click)="onCancel()" type="button" <button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button> class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div> </div>
<div after *ngIf="(canEdit$ | async) && !groupBeingEdited?.permanent" class="btn-group"> @if ((canEdit$ | async) && !(activeGroup$ | async)?.permanent) {
<button (click)="delete()" class="btn btn-danger delete-button" type="button"> <div after class="btn-group">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}} <button (click)="delete()" class="btn btn-danger delete-button" type="button">
</button> <i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</div> </button>
</ds-form> </div>
}
</ds-form>
<div class="mb-5"> @if ((activeGroup$ | async); as groupBeingEdited) {
<ds-members-list *ngIf="groupBeingEdited !== undefined" <div class="mb-5">
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list> @if (groupBeingEdited !== undefined) {
<ds-members-list
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
}
</div>
@if (groupBeingEdited !== undefined) {
<ds-subgroups-list
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
}
}
</div> </div>
<ds-subgroups-list *ngIf="groupBeingEdited !== undefined"
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
</div> </div>
</div> </div>
</div>

View File

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

View File

@@ -1,7 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgIf,
} from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@@ -11,7 +8,10 @@ import {
OnInit, OnInit,
Output, Output,
} from '@angular/core'; } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms'; import {
AbstractControl,
UntypedFormGroup,
} from '@angular/forms';
import { import {
ActivatedRoute, ActivatedRoute,
Router, Router,
@@ -31,13 +31,10 @@ import { Operation } from 'fast-json-patch';
import { import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf,
Subscription, Subscription,
} from 'rxjs'; } from 'rxjs';
import { import {
catchError,
debounceTime, debounceTime,
filter,
map, map,
switchMap, switchMap,
take, take,
@@ -53,7 +50,6 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service';
import { Group } from '../../../core/eperson/models/group.model'; import { Group } from '../../../core/eperson/models/group.model';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
@@ -61,9 +57,9 @@ import { Community } from '../../../core/shared/community.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { import {
getAllCompletedRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload, getRemoteDataPayload,
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { AlertComponent } from '../../../shared/alert/alert.component'; import { AlertComponent } from '../../../shared/alert/alert.component';
@@ -93,7 +89,6 @@ import { ValidateGroupExists } from './validators/group-exists.validator';
imports: [ imports: [
FormComponent, FormComponent,
AlertComponent, AlertComponent,
NgIf,
AsyncPipe, AsyncPipe,
TranslateModule, TranslateModule,
ContextHelpDirective, ContextHelpDirective,
@@ -117,9 +112,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
/** /**
* Dynamic models for the inputs of form * Dynamic models for the inputs of form
*/ */
groupName: DynamicInputModel; groupName: AbstractControl;
groupCommunity: DynamicInputModel; groupCommunity: AbstractControl;
groupDescription: DynamicTextAreaModel; groupDescription: AbstractControl;
/** /**
* A list of all dynamic input models * A list of all dynamic input models
@@ -162,21 +157,30 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/ */
subs: Subscription[] = []; 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 * 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>; canEdit$: Observable<boolean>;
/** /**
* The AlertType enumeration * The current {@link Group}
* @type {AlertType}
*/ */
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 * Subscription to email field value change
@@ -186,126 +190,121 @@ export class GroupFormComponent implements OnInit, OnDestroy {
constructor( constructor(
public groupDataService: GroupDataService, public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService, protected dSpaceObjectDataService: DSpaceObjectDataService,
private dSpaceObjectDataService: DSpaceObjectDataService, protected formBuilderService: FormBuilderService,
private formBuilderService: FormBuilderService, protected translateService: TranslateService,
private translateService: TranslateService, protected notificationsService: NotificationsService,
private notificationsService: NotificationsService, protected route: ActivatedRoute,
private route: ActivatedRoute,
protected router: Router, protected router: Router,
private authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
private modalService: NgbModal, protected modalService: NgbModal,
public requestService: RequestService, public requestService: RequestService,
protected changeDetectorRef: ChangeDetectorRef, protected changeDetectorRef: ChangeDetectorRef,
public dsoNameService: DSONameService, 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(); this.initialisePage();
} }
initialisePage() { initialisePage() {
this.subs.push(this.route.params.subscribe((params) => { const groupNameModel = new DynamicInputModel({
if (params.groupId !== 'newGroup') { id: 'groupName',
this.setActiveGroup(params.groupId); label: this.translateService.instant(`${this.messagePrefix}.groupName`),
} name: 'groupName',
})); validators: {
this.canEdit$ = this.groupDataService.getActiveGroup().pipe( required: null,
hasValueOperator(), },
switchMap((group: Group) => { required: true,
return observableCombineLatest([ });
this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), const groupCommunityModel = new DynamicInputModel({
this.hasLinkedDSO(group), id: 'groupCommunity',
]).pipe( label: this.translateService.instant(`${this.messagePrefix}.groupCommunity`),
map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO), 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 +323,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* Emit the updated/created eperson using the EventEmitter submitForm * Emit the updated/created eperson using the EventEmitter submitForm
*/ */
onSubmit() { onSubmit() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe( this.activeGroup$.pipe(take(1)).subscribe((group: Group) => {
(group: Group) => { if (group === null) {
const values = { this.createNewGroup({
name: this.groupName.value, name: this.groupName.value,
metadata: { metadata: {
'dc.description': [ 'dc.description': [
@@ -335,14 +334,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
}, },
], ],
}, },
}; });
if (group === null) { } else {
this.createNewGroup(values); this.editGroup(group);
} else { }
this.editGroup(group); });
}
},
);
} }
/** /**
@@ -448,7 +444,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* @param groupSelfLink SelfLink of group to set as active * @param groupSelfLink SelfLink of group to set as active
*/ */
setActiveGroupWithLink(groupSelfLink: string) { setActiveGroupWithLink(groupSelfLink: string) {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.activeGroup$.pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup === null) { if (activeGroup === null) {
this.groupDataService.cancelEditGroup(); this.groupDataService.cancelEditGroup();
this.groupDataService.findByHref(groupSelfLink, false, false, followLink('subgroups'), followLink('epersons'), followLink('object')) this.groupDataService.findByHref(groupSelfLink, false, false, followLink('subgroups'), followLink('epersons'), followLink('object'))
@@ -467,7 +463,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. * It'll either show a success or error message depending on whether the delete was successful or not.
*/ */
delete() { delete() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { this.activeGroup$.pipe(take(1)).subscribe((group: Group) => {
const modalRef = this.modalService.open(ConfirmationModalComponent); const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.name = this.dsoNameService.getName(group); modalRef.componentInstance.name = this.dsoNameService.getName(group);
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header';
@@ -511,52 +507,38 @@ export class GroupFormComponent implements OnInit, OnDestroy {
} }
/** /**
* Check if group has a linked object (community or collection linked to a workflow group) * Get the active {@link Group}'s linked object if it has one ({@link Community} or {@link Collection} linked to a
* @param group * workflow group)
*/ */
hasLinkedDSO(group: Group): Observable<boolean> { getActiveGroupLinkedDSO(): Observable<DSpaceObject> {
if (hasValue(group) && hasValue(group._links.object.href)) { return this.activeGroup$.pipe(
return this.getLinkedDSO(group).pipe( hasValueOperator(),
map((rd: RemoteData<DSpaceObject>) => { switchMap((group: Group) => {
return hasValue(rd) && hasValue(rd.payload); if (group.object === undefined) {
}), return this.dSpaceObjectDataService.findByHref(group._links.object.href);
catchError(() => observableOf(false)), }
); return group.object;
} }),
getAllCompletedRemoteData(),
getRemoteDataPayload(),
);
} }
/** /**
* Get group's linked object if it has one (community or collection linked to a workflow group) * Get the route to the edit roles tab of the active {@link Group}'s linked object (community or collection linked
* @param group * to a workflow group) if it has one
*/ */
getLinkedDSO(group: Group): Observable<RemoteData<DSpaceObject>> { getLinkedEditRolesRoute(): Observable<string> {
if (hasValue(group) && hasValue(group._links.object.href)) { return this.activeGroupLinkedDSO$.pipe(
if (group.object === undefined) { hasValueOperator(),
return this.dSpaceObjectDataService.findByHref(group._links.object.href); map((dso: DSpaceObject) => {
} switch ((dso as any).type) {
return group.object; case Community.type.value:
} return getCommunityEditRolesRoute(dso.id);
} case Collection.type.value:
return getCollectionEditRolesRoute(dso.id);
/** }
* 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);
}
}
}),
);
}
} }
} }

View File

@@ -3,63 +3,70 @@
<h3>{{messagePrefix + '.headMembers' | translate}}</h3> <h3>{{messagePrefix + '.headMembers' | translate}}</h3>
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0" @if ((ePeopleMembersOfGroup | async)?.totalElements > 0) {
[paginationOptions]="config" <ds-pagination
[collectionSize]="(ePeopleMembersOfGroup | async)?.totalElements" [paginationOptions]="config"
[hideGear]="true" [collectionSize]="(ePeopleMembersOfGroup | async)?.totalElements"
[hidePagerWhenSinglePage]="true"> [hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
@for (epersonDTO of (ePeopleMembersOfGroup | async)?.page; track epersonDTO) {
<tr>
<td class="align-middle">{{epersonDTO.eperson.id}}</td>
<td class="align-middle">
<a [routerLink]="getEPersonEditRoute(epersonDTO.eperson.id)">
{{ dsoNameService.getName(epersonDTO.eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ epersonDTO.eperson.email ? epersonDTO.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ epersonDTO.eperson.netid ? epersonDTO.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
@if (epersonDTO.ableToDelete) {
<button (click)="deleteMemberFromGroup(epersonDTO.eperson)"
[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>
}
@if (!epersonDTO.ableToDelete) {
<button
(click)="addMemberToGroup(epersonDTO.eperson)"
[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>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div class="table-responsive"> @if ((ePeopleMembersOfGroup | async) === undefined || (ePeopleMembersOfGroup | async)?.totalElements === 0) {
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered"> <div class="alert alert-info w-100 mb-2"
<thead> role="alert">
<tr> {{messagePrefix + '.no-members-yet' | translate}}
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDTO of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{epersonDTO.eperson.id}}</td>
<td class="align-middle">
<a [routerLink]="getEPersonEditRoute(epersonDTO.eperson.id)">
{{ dsoNameService.getName(epersonDTO.eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ epersonDTO.eperson.email ? epersonDTO.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ epersonDTO.eperson.netid ? epersonDTO.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(epersonDTO.eperson)"
*ngIf="epersonDTO.ableToDelete"
[disabled]="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"
[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>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div> </div>
}
</ds-pagination>
<div *ngIf="(ePeopleMembersOfGroup | async) === undefined || (ePeopleMembersOfGroup | async)?.totalElements === 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-members-yet' | translate}}
</div>
<h3 id="search" class="border-bottom pb-2"> <h3 id="search" class="border-bottom pb-2">
<span <span
@@ -69,77 +76,81 @@
iconPlacement: 'right', iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom'] tooltipPlacement: ['top', 'right', 'bottom']
}" }"
> >
{{messagePrefix + '.search.head' | translate}} {{messagePrefix + '.search.head' | translate}}
</span> </span>
</h3> </h3>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 me-3">
<div class="form-group input-group mr-3"> <div class="form-group input-group me-3">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input"> class="form-control" aria-label="Search input">
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}</button> <i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}</button>
</span> </span>
</div>
</div> </div>
</div> <div>
<div> <button (click)="clearFormAndResetResult();"
<button (click)="clearFormAndResetResult();" class="btn btn-secondary">{{messagePrefix + '.button.see-all' | translate}}</button>
class="btn btn-secondary">{{messagePrefix + '.button.see-all' | translate}}</button> </div>
</div> </form>
</form>
<ds-pagination *ngIf="(ePeopleSearch | async)?.totalElements > 0" @if ((ePeopleSearch | async)?.totalElements > 0) {
[paginationOptions]="configSearch" <ds-pagination
[collectionSize]="(ePeopleSearch | async)?.totalElements" [paginationOptions]="configSearch"
[hideGear]="true" [collectionSize]="(ePeopleSearch | async)?.totalElements"
[hidePagerWhenSinglePage]="true"> [hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
@for (eperson of (ePeopleSearch | async)?.page; track eperson) {
<tr>
<td class="align-middle">{{eperson.id}}</td>
<td class="align-middle">
<a [routerLink]="getEPersonEditRoute(eperson.id)">
{{ dsoNameService.getName(eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="addMemberToGroup(eperson)"
[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>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div class="table-responsive"> @if ((ePeopleSearch | async)?.totalElements === 0 && searchDone) {
<table id="epersonsSearch" class="table table-striped table-hover table-bordered"> <div
<thead> class="alert alert-info w-100 mb-2"
<tr> role="alert">
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th> {{messagePrefix + '.no-items' | translate}}
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th> </div>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th> }
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeopleSearch | async)?.page">
<td class="align-middle">{{eperson.id}}</td>
<td class="align-middle">
<a [routerLink]="getEPersonEditRoute(eperson.id)">
{{ dsoNameService.getName(eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="addMemberToGroup(eperson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination> </ng-container>
<div *ngIf="(ePeopleSearch | async)?.totalElements === 0 && searchDone"
class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-items' | translate}}
</div>
</ng-container>

View File

@@ -1,8 +1,6 @@
import { import {
AsyncPipe, AsyncPipe,
NgClass, NgClass,
NgForOf,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
Component, Component,
@@ -54,6 +52,7 @@ import {
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getRemoteDataPayload, getRemoteDataPayload,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive';
import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { ContextHelpDirective } from '../../../../shared/context-help.directive';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
@@ -108,11 +107,10 @@ export interface EPersonListActionConfig {
ContextHelpDirective, ContextHelpDirective,
ReactiveFormsModule, ReactiveFormsModule,
PaginationComponent, PaginationComponent,
NgIf,
AsyncPipe, AsyncPipe,
RouterLink, RouterLink,
NgClass, NgClass,
NgForOf, BtnDisabledDirective,
], ],
standalone: true, standalone: true,
}) })

View File

@@ -3,51 +3,56 @@
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4> <h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0" @if ((subGroups$ | async)?.payload?.totalElements > 0) {
[paginationOptions]="config" <ds-pagination
[collectionSize]="(subGroups$ | async)?.payload?.totalElements" [paginationOptions]="config"
[hideGear]="true" [collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hidePagerWhenSinglePage]="true"> [hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive"> <div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered"> <table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead> <thead>
<tr> <tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th> <th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page"> @for (group of (subGroups$ | async)?.payload?.page; track group) {
<td class="align-middle">{{group.id}}</td> <tr>
<td class="align-middle"> <td class="align-middle">{{group.id}}</td>
<a (click)="groupDataService.startEditingNewGroup(group)" <td class="align-middle">
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]"> <a (click)="groupDataService.startEditingNewGroup(group)"
{{ dsoNameService.getName(group) }} [routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
</a> {{ dsoNameService.getName(group) }}
</td> </a>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td> </td>
<td class="align-middle"> <td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<div class="btn-group edit-field"> <td class="align-middle">
<button (click)="deleteSubgroupFromGroup(group)" <div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton" class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> }
</table> </tbody>
</div> </table>
</ds-pagination> </div>
</ds-pagination>
}
<div *ngIf="(subGroups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2" @if ((subGroups$ | async)?.payload?.totalElements === 0) {
role="alert"> <div class="alert alert-info w-100 mb-2"
{{messagePrefix + '.no-subgroups-yet' | translate}} role="alert">
</div> {{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
}
<h4 id="search" class="border-bottom pb-2"> <h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{ <span *dsContextHelp="{
@@ -56,75 +61,80 @@
iconPlacement: 'right', iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom'] tooltipPlacement: ['top', 'right', 'bottom']
}" }"
> >
{{messagePrefix + '.search.head' | translate}} {{messagePrefix + '.search.head' | translate}}
</span> </span>
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 me-3">
<div class="form-group input-group mr-3"> <div class="mb-3 input-group me-3">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input"> class="form-control" aria-label="Search input">
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }} <i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}
</button> </button>
</span> </span>
</div> </div>
</div> </div>
<div> <div>
<button (click)="clearFormAndResetResult();" class="btn btn-secondary float-right"> <button (click)="clearFormAndResetResult();" class="btn btn-secondary float-end">
{{messagePrefix + '.button.see-all' | translate}} {{messagePrefix + '.button.see-all' | translate}}
</button> </button>
</div> </div>
</form> </form>
<ds-pagination *ngIf="(searchResults$ | async)?.payload?.totalElements > 0" @if ((searchResults$ | async)?.payload?.totalElements > 0) {
[paginationOptions]="configSearch" <ds-pagination
[collectionSize]="(searchResults$ | async)?.payload?.totalElements" [paginationOptions]="configSearch"
[hideGear]="true" [collectionSize]="(searchResults$ | async)?.payload?.totalElements"
[hidePagerWhenSinglePage]="true"> [hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive"> <div class="table-responsive">
<table id="groupsSearch" class="table table-striped table-hover table-bordered"> <table id="groupsSearch" class="table table-striped table-hover table-bordered">
<thead> <thead>
<tr> <tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th> <th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th> <th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page"> @for (group of (searchResults$ | async)?.payload?.page; track group) {
<td class="align-middle">{{group.id}}</td> <tr>
<td class="align-middle"> <td class="align-middle">{{group.id}}</td>
<a (click)="groupDataService.startEditingNewGroup(group)" <td class="align-middle">
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]"> <a (click)="groupDataService.startEditingNewGroup(group)"
{{ dsoNameService.getName(group) }} [routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
</a> {{ dsoNameService.getName(group) }}
</td> </a>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td> </td>
<td class="align-middle"> <td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td>
<div class="btn-group edit-field"> <td class="align-middle">
<button (click)="addSubgroupToGroup(group)" <div class="btn-group edit-field">
<button (click)="addSubgroupToGroup(group)"
class="btn btn-outline-primary btn-sm addButton" class="btn btn-outline-primary btn-sm addButton"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-plus fa-fw"></i> <i class="fas fa-plus fa-fw"></i>
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> }
</table> </tbody>
</div> </table>
</ds-pagination> </div>
</ds-pagination>
}
<div *ngIf="(searchResults$ | async)?.payload?.totalElements === 0 && searchDone" class="alert alert-info w-100 mb-2" @if ((searchResults$ | async)?.payload?.totalElements === 0 && searchDone) {
role="alert"> <div class="alert alert-info w-100 mb-2"
{{messagePrefix + '.no-items' | translate}} role="alert">
</div> {{messagePrefix + '.no-items' | translate}}
</div>
}
</ng-container> </ng-container>

View File

@@ -1,8 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
Input, Input,
@@ -65,12 +61,10 @@ enum SubKey {
imports: [ imports: [
RouterLink, RouterLink,
AsyncPipe, AsyncPipe,
NgForOf,
ContextHelpDirective, ContextHelpDirective,
TranslateModule, TranslateModule,
ReactiveFormsModule, ReactiveFormsModule,
PaginationComponent, PaginationComponent,
NgIf,
], ],
standalone: true, standalone: true,
}) })

View File

@@ -4,21 +4,21 @@
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h1 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h1> <h1 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h1>
<div> <div>
<button class="mr-auto btn btn-success" <button class="me-auto btn btn-success"
[routerLink]="'create'"> [routerLink]="'create'">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ms-1">{{messagePrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
<h2 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h2> <h2 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}</h2>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3"> <div class="flex-grow-1 me-3">
<div class="form-group input-group"> <div class="mb-3 input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate" class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" > [placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
<span class="input-group-append"> <span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ messagePrefix + 'search.button' | translate }} <i class="fas fa-search"></i> {{ messagePrefix + 'search.button' | translate }}
@@ -33,66 +33,78 @@
</div> </div>
</form> </form>
<ds-loading *ngIf="loading$ | async"></ds-loading> @if (loading$ | async) {
<ds-pagination <ds-loading></ds-loading>
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true" }
[paginationOptions]="config" @if ((pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true) {
[collectionSize]="(pageInfoState$ | async)?.totalElements" <ds-pagination
[hideGear]="true" [paginationOptions]="config"
[hidePagerWhenSinglePage]="true"> [collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
<th>{{messagePrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
@for (groupDto of (groupsDto$ | async)?.page; track groupDto) {
<tr>
<td>{{groupDto.group.id}}</td>
<td>{{ dsoNameService.getName(groupDto.group) }}</td>
<td>{{ dsoNameService.getName((groupDto.group.object | async)?.payload) }}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td>
<div class="btn-group edit-field">
@switch (groupDto.ableToEdit) {
@case (true) {
<button
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
class="btn btn-outline-primary btn-sm btn-edit"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: dsoNameService.getName(groupDto.group) } }}"
>
<i class="fas fa-edit fa-fw"></i>
</button>
}
@case (false) {
<button
[dsBtnDisabled]="true"
class="btn btn-outline-primary btn-sm btn-edit"
placement="left"
[ngbTooltip]="'admin.access-control.epeople.table.edit.buttons.edit-disabled' | translate"
>
<i class="fas fa-edit fa-fw"></i>
</button>
}
}
@if (!groupDto.group?.permanent && groupDto.ableToDelete) {
<button
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: dsoNameService.getName(groupDto.group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div class="table-responsive"> @if ((pageInfoState$ | async)?.totalElements === 0) {
<table id="groups" class="table table-striped table-hover table-bordered"> <div class="alert alert-info w-100 mb-2" role="alert">
<thead> {{messagePrefix + 'no-items' | translate}}
<tr>
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
<th>{{messagePrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td>
<td>{{ dsoNameService.getName(groupDto.group) }}</td>
<td>{{ dsoNameService.getName((groupDto.group.object | async)?.payload) }}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td>
<div class="btn-group edit-field">
<ng-container [ngSwitch]="groupDto.ableToEdit">
<button *ngSwitchCase="true"
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
class="btn btn-outline-primary btn-sm btn-edit"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: dsoNameService.getName(groupDto.group) } }}"
>
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngSwitchCase="false"
[disabled]="true"
class="btn btn-outline-primary btn-sm btn-edit"
placement="left"
[ngbTooltip]="'admin.access-control.epeople.table.edit.buttons.edit-disabled' | translate"
>
<i class="fas fa-edit fa-fw"></i>
</button>
</ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: dsoNameService.getName(groupDto.group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div> </div>
</ds-pagination> }
<div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
{{messagePrefix + 'no-items' | translate}}
</div>
</div> </div>
</div> </div>

View File

@@ -50,6 +50,7 @@ import { RouteService } from '../../core/services/route.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { NoContent } from '../../core/shared/NoContent.model'; import { NoContent } from '../../core/shared/NoContent.model';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { import {
DSONameServiceMock, DSONameServiceMock,
UNDEFINED_NAME, UNDEFINED_NAME,
@@ -208,6 +209,7 @@ describe('GroupsRegistryComponent', () => {
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot(), TranslateModule.forRoot(),
GroupsRegistryComponent, GroupsRegistryComponent,
BtnDisabledDirective,
], ],
providers: [GroupsRegistryComponent, providers: [GroupsRegistryComponent,
{ provide: DSONameService, useValue: new DSONameServiceMock() }, { 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')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { 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')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { 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')); const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2); expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => { 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

@@ -1,10 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgForOf,
NgIf,
NgSwitch,
NgSwitchCase,
} from '@angular/common';
import { import {
Component, Component,
OnDestroy, OnDestroy,
@@ -62,6 +56,7 @@ import {
getRemoteDataPayload, getRemoteDataPayload,
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { PageInfo } from '../../core/shared/page-info.model'; import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -78,12 +73,9 @@ import { followLink } from '../../shared/utils/follow-link-config.model';
RouterLink, RouterLink,
ReactiveFormsModule, ReactiveFormsModule,
AsyncPipe, AsyncPipe,
NgIf,
PaginationComponent, PaginationComponent,
NgSwitch,
NgSwitchCase,
NgbTooltipModule, NgbTooltipModule,
NgForOf, BtnDisabledDirective,
], ],
standalone: true, standalone: true,
}) })

View File

@@ -1,14 +1,16 @@
<div class="container"> <div class="container">
<h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1> <h1 id="header">{{'admin.batch-import.page.header' | translate}}</h1>
<p>{{'admin.batch-import.page.help' | translate}}</p> <p>{{'admin.batch-import.page.help' | translate}}</p>
<p *ngIf="dso"> @if (dso) {
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp; <p>
<a href="javascript:void(0)" (click)="removeDspaceObject()">{{'admin.batch-import.page.remove' | translate}}</a> selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;
</p> <a href="javascript:void(0)" (click)="removeDspaceObject()">{{'admin.batch-import.page.remove' | translate}}</a>
</p>
}
<p> <p>
<button class="btn btn-primary" (click)="this.selectCollection();">{{'admin.metadata-import.page.button.select-collection' | translate}}</button> <button class="btn btn-primary" (click)="this.selectCollection();">{{'admin.metadata-import.page.button.select-collection' | translate}}</button>
</p> </p>
<div class="form-group"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly"> <input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly"> <label class="form-check-label" for="validateOnly">
@@ -21,32 +23,35 @@
</div> </div>
<ui-switch color="#ebebeb" <ui-switch color="#ebebeb"
[checkedLabel]="'admin.metadata-import.page.toggle.upload' | translate" [checkedLabel]="'admin.metadata-import.page.toggle.upload' | translate"
[uncheckedLabel]="'admin.metadata-import.page.toggle.url' | translate" [uncheckedLabel]="'admin.metadata-import.page.toggle.url' | translate"
[checked]="isUpload" [checked]="isUpload"
(change)="toggleUpload()" ></ui-switch> (change)="toggleUpload()" ></ui-switch>
<small class="form-text text-muted"> <small class="form-text text-muted d-block">
{{'admin.batch-import.page.toggle.help' | translate}} {{'admin.batch-import.page.toggle.help' | translate}}
</small> </small>
<ds-file-dropzone-no-uploader @if (isUpload) {
*ngIf="isUpload" <ds-file-dropzone-no-uploader
data-test="file-dropzone" data-test="file-dropzone"
(onFileAdded)="setFile($event)" (onFileAdded)="setFile($event)"
[dropMessageLabel]="'admin.batch-import.page.dropMsg'" [dropMessageLabel]="'admin.batch-import.page.dropMsg'"
[dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'"> [dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader> </ds-file-dropzone-no-uploader>
}
<div class="form-group mt-2" *ngIf="!isUpload"> @if (!isUpload) {
<input class="form-control" type="text" placeholder="{{'admin.metadata-import.page.urlMsg' | translate}}" <div class="mb-3 mt-2">
data-test="file-url-input" [(ngModel)]="fileURL"> <input class="form-control" type="text" placeholder="{{'admin.metadata-import.page.urlMsg' | translate}}"
</div> data-test="file-url-input" [(ngModel)]="fileURL">
</div>
}
<div class="space-children-mr"> <div class="space-children-mr">
<button class="btn btn-secondary" id="backButton" <button class="btn btn-secondary" id="backButton"
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button> (click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
<button class="btn btn-primary" id="proceedButton" <button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button> (click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,4 @@
import { import { Location } from '@angular/common';
Location,
NgIf,
} from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -36,7 +33,6 @@ import { FileDropzoneNoUploaderComponent } from '../../shared/upload/file-dropzo
selector: 'ds-batch-import-page', selector: 'ds-batch-import-page',
templateUrl: './batch-import-page.component.html', templateUrl: './batch-import-page.component.html',
imports: [ imports: [
NgIf,
TranslateModule, TranslateModule,
FormsModule, FormsModule,
UiSwitchModule, UiSwitchModule,

View File

@@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<h1 id="header">{{'admin.metadata-import.page.header' | translate}}</h1> <h1 id="header">{{'admin.metadata-import.page.header' | translate}}</h1>
<p>{{'admin.metadata-import.page.help' | translate}}</p> <p>{{'admin.metadata-import.page.help' | translate}}</p>
<div class="form-group"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly"> <input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly"> <label class="form-check-label" for="validateOnly">

View File

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

View File

@@ -5,11 +5,7 @@ import {
transition, transition,
trigger, trigger,
} from '@angular/animations'; } from '@angular/animations';
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@@ -77,9 +73,7 @@ import { notifyPatterns } from '../ldn-services-patterns/ldn-service-coar-patter
imports: [ imports: [
ReactiveFormsModule, ReactiveFormsModule,
TranslateModule, TranslateModule,
NgIf,
NgbDropdownModule, NgbDropdownModule,
NgForOf,
AsyncPipe, AsyncPipe,
], ],
}) })
@@ -131,6 +125,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''], score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''],
constraintPattern: [''], constraintPattern: [''],
enabled: [''], enabled: [''],
usesActorEmailId: [''],
type: LDN_SERVICE.value, type: LDN_SERVICE.value,
}); });
} }
@@ -184,7 +179,8 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
return rest; return rest;
}); });
const values = { ...this.formModel.value, enabled: true }; const values = { ...this.formModel.value, enabled: true,
usesActorEmailId: this.formModel.get('usesActorEmailId').value };
const ldnServiceData = this.ldnServicesService.create(values); const ldnServiceData = this.ldnServicesService.create(values);
@@ -243,6 +239,7 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
ldnUrl: this.ldnService.ldnUrl, ldnUrl: this.ldnService.ldnUrl,
type: this.ldnService.type, type: this.ldnService.type,
enabled: this.ldnService.enabled, enabled: this.ldnService.enabled,
usesActorEmailId: this.ldnService.usesActorEmailId,
lowerIp: this.ldnService.lowerIp, lowerIp: this.ldnService.lowerIp,
upperIp: this.ldnService.upperIp, upperIp: this.ldnService.upperIp,
}); });
@@ -390,6 +387,32 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy {
); );
} }
/**
* Toggles the usesActorEmailId field of the LDN service by sending a patch request
*/
toggleUsesActorEmailId() {
const newStatus = !this.formModel.get('usesActorEmailId').value;
if (!this.isNewService) {
const patchOperation: Operation = {
op: 'replace',
path: '/usesActorEmailId',
value: newStatus,
};
this.ldnServicesService.patch(this.ldnService, [patchOperation]).pipe(
getFirstCompletedRemoteData(),
).subscribe(
() => {
this.formModel.get('usesActorEmailId').setValue(newStatus);
this.cdRef.detectChanges();
},
);
} else {
this.formModel.get('usesActorEmailId').setValue(newStatus);
this.cdRef.detectChanges();
}
}
/** /**
* Closes the modal * Closes the modal
*/ */

View File

@@ -12,6 +12,7 @@ import { LdnService } from '../ldn-services-model/ldn-services.model';
export const mockLdnService: LdnService = { export const mockLdnService: LdnService = {
uuid: '1', uuid: '1',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 1, id: 1,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',
@@ -49,6 +50,7 @@ export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnServic
export const mockLdnServices: LdnService[] = [{ export const mockLdnServices: LdnService[] = [{
uuid: '1', uuid: '1',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 1, id: 1,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',
@@ -81,6 +83,7 @@ export const mockLdnServices: LdnService[] = [{
}, { }, {
uuid: '2', uuid: '2',
enabled: false, enabled: false,
usesActorEmailId: false,
score: 0, score: 0,
id: 2, id: 2,
lowerIp: '192.0.2.146', lowerIp: '192.0.2.146',

View File

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

View File

@@ -1,8 +1,6 @@
import { import {
AsyncPipe, AsyncPipe,
NgClass, NgClass,
NgFor,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -54,8 +52,6 @@ import { LdnService } from '../ldn-services-model/ldn-services.model';
styleUrls: ['./ldn-services-directory.component.scss'], styleUrls: ['./ldn-services-directory.component.scss'],
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
imports: [ imports: [
NgIf,
NgFor,
TranslateModule, TranslateModule,
AsyncPipe, AsyncPipe,
PaginationComponent, PaginationComponent,

View File

@@ -52,6 +52,9 @@ export class LdnService extends CacheableObject {
@autoserialize @autoserialize
enabled: boolean; enabled: boolean;
@autoserialize
usesActorEmailId: boolean;
@autoserialize @autoserialize
ldnUrl: string; ldnUrl: string;

View File

@@ -1 +1 @@
<ds-publication-claim [source]="'openaire'"></ds-publication-claim> <ds-suggestion-sources></ds-suggestion-sources>

View File

@@ -6,8 +6,9 @@ import {
waitForAsync, waitForAsync,
} from '@angular/core/testing'; } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';
import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component'; import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component';
import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component'; import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component';
describe('AdminNotificationsPublicationClaimPageComponent', () => { describe('AdminNotificationsPublicationClaimPageComponent', () => {
@@ -20,17 +21,10 @@ describe('AdminNotificationsPublicationClaimPageComponent', () => {
CommonModule, CommonModule,
TranslateModule.forRoot(), TranslateModule.forRoot(),
AdminNotificationsPublicationClaimPageComponent, AdminNotificationsPublicationClaimPageComponent,
], MockComponent(SuggestionSourcesComponent),
providers: [
AdminNotificationsPublicationClaimPageComponent,
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(AdminNotificationsPublicationClaimPageComponent, { }).compileComponents();
remove: {
imports: [PublicationClaimComponent],
},
})
.compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {

View File

@@ -1,14 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component'; import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component';
@Component({ @Component({
selector: 'ds-admin-notifications-publication-claim-page', selector: 'ds-admin-notifications-publication-claim-page',
templateUrl: './admin-notifications-publication-claim-page.component.html', templateUrl: './admin-notifications-publication-claim-page.component.html',
styleUrls: ['./admin-notifications-publication-claim-page.component.scss'], styleUrls: ['./admin-notifications-publication-claim-page.component.scss'],
imports: [ imports: [ SuggestionSourcesComponent ],
PublicationClaimComponent,
],
standalone: true, standalone: true,
}) })
export class AdminNotificationsPublicationClaimPageComponent { export class AdminNotificationsPublicationClaimPageComponent {

View File

@@ -2,7 +2,8 @@ import { Route } from '@angular/router';
import { authenticatedGuard } from '../../core/auth/authenticated.guard'; import { authenticatedGuard } from '../../core/auth/authenticated.guard';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { qualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; import { sourcesBreadcrumbResolver } from '../../core/breadcrumbs/sources-breadcrumb.resolver';
import { PublicationClaimComponent } from '../../notifications/suggestions/targets/publication-claim/publication-claim.component';
import { AdminNotificationsPublicationClaimPageResolver } from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; import { AdminNotificationsPublicationClaimPageResolver } from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service';
import { QualityAssuranceEventsPageComponent } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component'; import { QualityAssuranceEventsPageComponent } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component';
import { qualityAssuranceEventsPageResolver } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver'; import { qualityAssuranceEventsPageResolver } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver';
@@ -33,13 +34,28 @@ export const ROUTES: Route[] = [
showBreadcrumbsFluid: false, showBreadcrumbsFluid: false,
}, },
}, },
{
canActivate: [ authenticatedGuard ],
path: `${PUBLICATION_CLAIMS_PATH}/:sourceId`,
pathMatch: 'full',
component: PublicationClaimComponent,
resolve: {
breadcrumb: sourcesBreadcrumbResolver,
openaireQualityAssuranceEventsParams: AdminNotificationsPublicationClaimPageResolver,
},
data: {
title: 'admin.notifications.publicationclaim.page.title',
breadcrumbKey: 'admin.notifications.publicationclaim',
showBreadcrumbsFluid: false,
},
},
{ {
canActivate: [authenticatedGuard], canActivate: [authenticatedGuard],
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
component: QualityAssuranceTopicsPageComponent, component: QualityAssuranceTopicsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: qualityAssuranceBreadcrumbResolver, breadcrumb: sourcesBreadcrumbResolver,
openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver, openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver,
}, },
data: { data: {
@@ -85,7 +101,7 @@ export const ROUTES: Route[] = [
component: QualityAssuranceEventsPageComponent, component: QualityAssuranceEventsPageComponent,
pathMatch: 'full', pathMatch: 'full',
resolve: { resolve: {
breadcrumb: qualityAssuranceBreadcrumbResolver, breadcrumb: sourcesBreadcrumbResolver,
openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver, openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver,
}, },
data: { data: {

View File

@@ -13,11 +13,13 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" [routerLink]="'outbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.outbound-logs' | translate}}</a> <a class="nav-link" [routerLink]="'outbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul> </ul>
<div class="mt-2"> <div class="mt-2">
<ds-admin-notify-metrics *ngIf="(notifyMetricsRows$ | async)?.length" [boxesConfig]="notifyMetricsRows$ | async"></ds-admin-notify-metrics> @if ((notifyMetricsRows$ | async)?.length) {
<ds-admin-notify-metrics [boxesConfig]="notifyMetricsRows$ | async"></ds-admin-notify-metrics>
}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@@ -1,7 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
Inject, Inject,
@@ -46,7 +43,6 @@ import {
imports: [ imports: [
AdminNotifyMetricsComponent, AdminNotifyMetricsComponent,
RouterLink, RouterLink,
NgIf,
TranslateModule, TranslateModule,
AsyncPipe, AsyncPipe,
], ],

View File

@@ -5,12 +5,14 @@
</button> </button>
</div> </div>
<div class="modal-body p-4"> <div class="modal-body p-4">
<div *ngFor="let key of notifyMessageKeys"> @for (key of notifyMessageKeys; track key) {
<div>
<div class="row mb-4"> <div class="row mb-4">
<div class="font-weight-bold col">{{ key + '.notify-detail-modal' | translate}}</div> <div class="font-weight-bold col">{{ key + '.notify-detail-modal' | translate}}</div>
<div class="col text-right">{{'notify-detail-modal.' + notifyMessage[key] | translate: {default: notifyMessage[key] ?? "n/a" } }}</div> <div class="col text-right">{{'notify-detail-modal.' + notifyMessage[key] | translate: {default: notifyMessage[key] ?? "n/a" } }}</div>
</div> </div>
</div> </div>
}
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button class="btn-primary" (click)="toggleCoarMessage()"> <button class="btn-primary" (click)="toggleCoarMessage()">
@@ -18,5 +20,7 @@
</button> </button>
</div> </div>
<pre @fadeIn [innerHTML]="notifyMessage.message" class="bg-secondary text-white mt-2 p-2" *ngIf="isCoarMessageVisible"></pre> @if (isCoarMessageVisible) {
<pre @fadeIn [innerHTML]="notifyMessage.message" class="bg-secondary text-white mt-2 p-2"></pre>
}
</div> </div>

View File

@@ -1,7 +1,4 @@
import {
NgForOf,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
EventEmitter, EventEmitter,
@@ -26,9 +23,7 @@ import { AdminNotifyMessage } from '../models/admin-notify-message.model';
], ],
standalone: true, standalone: true,
imports: [ imports: [
NgForOf,
TranslateModule, TranslateModule,
NgIf,
], ],
}) })
/** /**

View File

@@ -3,10 +3,12 @@
<div class="col-12 col-md-3 text-left h4">{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}</div> <div class="col-12 col-md-3 text-left h4">{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}</div>
<div class="col-md-9"> <div class="col-md-9">
<div class="h4"> <div class="h4">
<button (click)="resetDefaultConfiguration()" *ngIf="(selectedSearchConfig$ | async) !== defaultConfiguration" class="badge badge-primary mr-1 mb-1"> @if ((selectedSearchConfig$ | async) !== defaultConfiguration) {
{{ 'admin-notify-logs.' + (selectedSearchConfig$ | async) | translate}} <button (click)="resetDefaultConfiguration()" class="badge bg-primary me-1 mb-1">
<span> ×</span> {{ 'admin-notify-logs.' + (selectedSearchConfig$ | async) | translate}}
</button> <span> ×</span>
</button>
}
</div> </div>
<ds-search-labels [inPlaceSearch]="true"></ds-search-labels> <ds-search-labels [inPlaceSearch]="true"></ds-search-labels>
</div> </div>

View File

@@ -1,7 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
Inject, Inject,
@@ -39,7 +36,6 @@ import { ThemedSearchComponent } from '../../../../shared/search/themed-search.c
ThemedSearchComponent, ThemedSearchComponent,
AsyncPipe, AsyncPipe,
TranslateModule, TranslateModule,
NgIf,
], ],
}) })

View File

@@ -1,9 +1,13 @@
<div class="mb-5" *ngFor="let row of boxesConfig"> @for (row of boxesConfig; track row) {
<div class="mb-2">{{ row.title | translate }}</div> <div class="mb-5">
<div class="row justify-content-between"> <div class="mb-2">{{ row.title | translate }}</div>
<div class="col-sm" *ngFor="let box of row.boxes"> <div class="row justify-content-between">
<ds-notification-box (selectedBoxConfig)="navigateToSelectedSearchConfig($event)" [boxConfig]="box"></ds-notification-box> @for (box of row.boxes; track box) {
<div class="col-sm">
<ds-notification-box (selectedBoxConfig)="navigateToSelectedSearchConfig($event)" [boxConfig]="box"></ds-notification-box>
</div>
}
</div> </div>
</div> </div>
</div> }

View File

@@ -1,4 +1,4 @@
import { NgForOf } from '@angular/common';
import { import {
Component, Component,
Input, Input,
@@ -17,7 +17,6 @@ import { AdminNotifyMetricsRow } from './admin-notify-metrics.model';
imports: [ imports: [
NotificationBoxComponent, NotificationBoxComponent,
TranslateModule, TranslateModule,
NgForOf,
], ],
}) })
/** /**

View File

@@ -11,41 +11,57 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let message of (messagesSubject$ | async)"> @for (message of (messagesSubject$ | async); track message) {
<td class="text-nowrap"> <tr>
<div *ngIf="message.queueLastStartTime">{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}</div> <td class="text-nowrap">
<div *ngIf="!message.queueLastStartTime">n/a</div> @if (message.queueLastStartTime) {
</td> <div>{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}</div>
<td> }
<ds-truncatable [id]="message.id"> @if (!message.queueLastStartTime) {
<ds-truncatable-part [id]="message.id" [minLines]="2"> <div>n/a</div>
<a *ngIf="message.relatedItem" [routerLink]="'/items/' + (message.context || message.object)">{{ message.relatedItem }}</a> }
</ds-truncatable-part> </td>
</ds-truncatable> <td>
<div *ngIf="!message.relatedItem">n/a</div> <ds-truncatable [id]="message.id">
</td> <ds-truncatable-part [id]="message.id" [minLines]="2">
<td> @if (message.relatedItem) {
<div *ngIf="message.ldnService">{{ message.ldnService }}</div> <a [routerLink]="'/items/' + (message.context || message.object)">{{ message.relatedItem }}</a>
<div *ngIf="!message.ldnService">n/a</div> }
</td> </ds-truncatable-part>
<td> </ds-truncatable>
<div>{{ message.activityStreamType }}</div> @if (!message.relatedItem) {
</td> <div>n/a</div>
<td> }
<div class="text-nowrap">{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}</div> </td>
</td> <td>
<td> @if (message.ldnService) {
<div class="d-flex flex-column"> <div>{{ message.ldnService }}</div>
<button class="btn mb-2 btn-dark" (click)="openDetailModal(message)">{{ 'notify-message-result.detail' | translate }}</button> }
<button *ngIf="message.queueStatusLabel !== reprocessStatus && validStatusesForReprocess.includes(message.queueStatusLabel)" @if (!message.ldnService) {
(click)="reprocessMessage(message)" <div>n/a</div>
class="btn btn-warning" }
> </td>
{{ 'notify-message-result.reprocess' | translate }} <td>
</button> <div>{{ message.activityStreamType }}</div>
</div> </td>
</td> <td>
</tr> <div class="text-nowrap">{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}</div>
</td>
<td>
<div class="d-flex flex-column">
<button class="btn mb-2 btn-dark" (click)="openDetailModal(message)">{{ 'notify-message-result.detail' | translate }}</button>
@if (message.queueStatusLabel !== reprocessStatus && validStatusesForReprocess.includes(message.queueStatusLabel)) {
<button
(click)="reprocessMessage(message)"
class="btn btn-warning"
>
{{ 'notify-message-result.reprocess' | translate }}
</button>
}
</div>
</td>
</tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -1,8 +1,6 @@
import { import {
AsyncPipe, AsyncPipe,
DatePipe, DatePipe,
NgForOf,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
Component, Component,
@@ -42,8 +40,6 @@ import { AdminNotifyMessagesService } from '../services/admin-notify-messages.se
standalone: true, standalone: true,
imports: [ imports: [
TranslateModule, TranslateModule,
NgForOf,
NgIf,
DatePipe, DatePipe,
AsyncPipe, AsyncPipe,
TruncatableComponent, TruncatableComponent,

View File

@@ -8,51 +8,62 @@
<p id="create-new" class="mb-2"><a [routerLink]="'add'" class="btn btn-success">{{'admin.registries.bitstream-formats.create.new' | translate}}</a></p> <p id="create-new" class="mb-2"><a [routerLink]="'add'" class="btn btn-success">{{'admin.registries.bitstream-formats.create.new' | translate}}</a></p>
<ds-pagination @if ((bitstreamFormats$ | async)?.payload?.totalElements > 0) {
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0" <ds-pagination
[paginationOptions]="pageConfig" [paginationOptions]="pageConfig"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements" [collectionSize]="(bitstreamFormats$ | async)?.payload?.totalElements"
[hideGear]="false" [hideGear]="false"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
<div class="table-responsive"> <div class="table-responsive">
<table id="formats" class="table table-striped table-hover"> <table id="formats" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col"><span class="sr-only">{{'admin.registries.bitstream-formats.table.selected' | translate}}</span></th> <th scope="col"><span class="sr-only">{{'admin.registries.bitstream-formats.table.selected' | translate}}</span></th>
<th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page"> @for (bitstreamFormat of (bitstreamFormats$ | async)?.payload?.page; track bitstreamFormat) {
<td> <tr>
<label class="mb-0"> <td>
<input type="checkbox" <label class="form-label mb-0">
[attr.aria-label]="'admin.registries.bitstream-formats.select' | translate" <input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async" [attr.aria-label]="'admin.registries.bitstream-formats.select' | translate"
(change)="selectBitStreamFormat(bitstreamFormat, $event)" [checked]="(selectedBitstreamFormatIDs$ | async)?.includes(bitstreamFormat.id)"
> (change)="selectBitStreamFormat(bitstreamFormat, $event)"
<span class="sr-only">{{'admin.registries.bitstream-formats.select' | translate}}&#125;</span> >
</label> <span class="sr-only">{{'admin.registries.bitstream-formats.select' | translate}}&#125;</span>
</td> </label>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td> </td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} @if (bitstreamFormat.internal) {
</tr> <span>({{'admin.registries.bitstream-formats.table.internal' | translate}})</span>
</tbody> }</a></td>
</table> <td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
@if ((bitstreamFormats$ | async)?.payload?.totalElements === 0) {
<div class="alert alert-info" role="alert">
{{'admin.registries.bitstream-formats.no-items' | translate}}
</div> </div>
</ds-pagination> }
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements === 0" class="alert alert-info" role="alert">
{{'admin.registries.bitstream-formats.no-items' | translate}}
</div>
<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> @if ((bitstreamFormats$ | async)?.payload?.page?.length > 0) {
<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 class="btn btn-primary deselect" (click)="deselectAll()">{{'admin.registries.bitstream-formats.table.deselect-all' | translate}}</button>
}
@if ((bitstreamFormats$ | async)?.payload?.page?.length > 0) {
<button type="submit" class="btn btn-danger float-end" (click)="deleteFormats()">{{'admin.registries.bitstream-formats.table.delete' | translate}}</button>
}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,8 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
OnDestroy, OnDestroy,
@@ -13,10 +9,7 @@ import {
TranslateModule, TranslateModule,
TranslateService, TranslateService,
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { import { Observable } from 'rxjs';
combineLatest as observableCombineLatest,
Observable,
} from 'rxjs';
import { import {
map, map,
mergeMap, mergeMap,
@@ -44,12 +37,10 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio
selector: 'ds-bitstream-formats', selector: 'ds-bitstream-formats',
templateUrl: './bitstream-formats.component.html', templateUrl: './bitstream-formats.component.html',
imports: [ imports: [
NgIf,
AsyncPipe, AsyncPipe,
RouterLink, RouterLink,
TranslateModule, TranslateModule,
PaginationComponent, PaginationComponent,
NgForOf,
], ],
standalone: true, standalone: true,
}) })
@@ -58,7 +49,12 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
/** /**
* A paginated list of bitstream formats to be shown on the page * 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 * The current pagination configuration for the page
@@ -125,14 +121,11 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
} }
/** /**
* Checks whether a given bitstream format is selected in the list (checkbox) * Returns the list of all the bitstream formats that are selected in the list (checkbox)
* @param bitstreamFormat
*/ */
isSelected(bitstreamFormat: BitstreamFormat): Observable<boolean> { selectedBitstreamFormatIDs(): Observable<string[]> {
return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe( return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(
map((bitstreamFormats: BitstreamFormat[]) => { map((bitstreamFormats: BitstreamFormat[]) => bitstreamFormats.map((selectedFormat) => selectedFormat.id)),
return bitstreamFormats.find((selectedFormat) => selectedFormat.id === bitstreamFormat.id) != null;
}),
); );
} }
@@ -156,27 +149,23 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
const prefix = 'admin.registries.bitstream-formats.delete'; const prefix = 'admin.registries.bitstream-formats.delete';
const suffix = success ? 'success' : 'failure'; const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest( const head: string = this.translateService.instant(`${prefix}.${suffix}.head`);
this.translateService.get(`${prefix}.${suffix}.head`), const content: string = this.translateService.instant(`${prefix}.${suffix}.amount`, { amount: amount });
this.translateService.get(`${prefix}.${suffix}.amount`, { amount: amount }),
);
messages.subscribe(([head, content]) => {
if (success) { if (success) {
this.notificationsService.success(head, content); this.notificationsService.success(head, content);
} else { } else {
this.notificationsService.error(head, content); this.notificationsService.error(head, content);
} }
});
} }
ngOnInit(): void { 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) => { switchMap((findListOptions: FindListOptions) => {
return this.bitstreamFormatService.findAll(findListOptions); return this.bitstreamFormatService.findAll(findListOptions);
}), }),
); );
this.selectedBitstreamFormatIDs$ = this.selectedBitstreamFormatIDs();
} }

View File

@@ -1,3 +1,5 @@
<ds-form *ngIf="formModel" @if (formModel) {
[formId]="'comcol-form-id'" <ds-form
[formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"></ds-form> [formId]="'comcol-form-id'"
[formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"></ds-form>
}

View File

@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import { import {
Component, Component,
EventEmitter, EventEmitter,
@@ -35,7 +35,6 @@ import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-p
templateUrl: './format-form.component.html', templateUrl: './format-form.component.html',
imports: [ imports: [
FormComponent, FormComponent,
NgIf,
], ],
standalone: true, standalone: true,
}) })
@@ -64,7 +63,7 @@ export class FormatFormComponent implements OnInit {
*/ */
arrayElementLayout: DynamicFormControlLayout = { arrayElementLayout: DynamicFormControlLayout = {
grid: { grid: {
group: 'form-row', group: 'row',
}, },
}; };

View File

@@ -1,60 +1,65 @@
<div class="container"> <div class="container">
<div class="metadata-registry row"> <div class="metadata-registry row">
<div class="col-12"> <div class="col-12">
<h1 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h1> <h1 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h1>
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p> <p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
<ds-metadata-schema-form (submitForm)="forceUpdateSchemas()"></ds-metadata-schema-form> <ds-metadata-schema-form (submitForm)="forceUpdateSchemas()"></ds-metadata-schema-form>
<ds-pagination @if ((metadataSchemas | async)?.payload?.totalElements > 0) {
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0" <ds-pagination
[paginationOptions]="config" [paginationOptions]="config"
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements" [collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
[hideGear]="true" [hideGear]="true"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<div class="table-responsive"> <table id="metadata-schemas" class="table table-striped table-hover">
<table id="metadata-schemas" class="table table-striped table-hover"> <thead>
<thead> <tr>
<tr> <th scope="col"><span class="sr-only">{{'admin.registries.metadata.schemas.table.selected' | translate}}</span></th>
<th scope="col"><span class="sr-only">{{'admin.registries.metadata.schemas.table.selected' | translate}}</span></th> <th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th> <th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th> <th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> @for (schema of (metadataSchemas | async)?.payload?.page; track schema) {
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page" <tr
[ngClass]="{'table-primary' : isActive(schema) | async}"> [ngClass]="{'table-primary' : (activeMetadataSchema$ | async)?.id === schema.id}">
<td> <td>
<label class="mb-0"> <label class="form-label mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(schema) | async" [checked]="(selectedMetadataSchemaIDs$ | async)?.includes(schema.id)"
(change)="selectMetadataSchema(schema, $event)" (change)="selectMetadataSchema(schema, $event)"
> >
<span class="sr-only">{{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span> <span class="sr-only">{{(((selectedMetadataSchemaIDs$ | async)?.includes(schema.id)) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span>
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td> <td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td> <td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td> <td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
</tr> </tr>
</tbody> }
</table> </tbody>
</div> </table>
</div>
</ds-pagination> </ds-pagination>
}
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
{{'admin.registries.metadata.schemas.no-items' | translate}}
</div>
<div>
<button *ngIf="(metadataSchemas | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteSchemas()">{{'admin.registries.metadata.schemas.table.delete' | translate}}</button>
</div>
@if ((metadataSchemas | async)?.payload?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2" role="alert">
{{'admin.registries.metadata.schemas.no-items' | translate}}
</div> </div>
}
<div>
@if ((metadataSchemas | async)?.payload?.page?.length > 0) {
<button type="submit" class="btn btn-danger float-end" (click)="deleteSchemas()">{{'admin.registries.metadata.schemas.table.delete' | translate}}</button>
}
</div>
</div> </div>
</div>
</div> </div>

View File

@@ -17,9 +17,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service'; 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 { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { GroupDataService } from '../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { PaginationService } from '../../../core/pagination/pagination.service'; 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 { HostWindowServiceStub } from '../../../shared/testing/host-window-service.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { PaginationServiceStub } from '../../../shared/testing/pagination-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 { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service.stub';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { MetadataRegistryComponent } from './metadata-registry.component'; import { MetadataRegistryComponent } from './metadata-registry.component';
import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-schema-form.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', () => { describe('MetadataRegistryComponent', () => {
let comp: MetadataRegistryComponent; let comp: MetadataRegistryComponent;
let fixture: ComponentFixture<MetadataRegistryComponent>; let fixture: ComponentFixture<MetadataRegistryComponent>;
let registryService: RegistryService;
let paginationService; let paginationService: PaginationServiceStub;
const mockSchemasList = [ let registryService: RegistryServiceStub;
const mockSchemasList: MetadataSchema[] = [
{ {
id: 1, id: 1,
_links: { _links: {
@@ -67,25 +69,7 @@ describe('MetadataRegistryComponent', () => {
prefix: 'mock', prefix: 'mock',
namespace: 'http://dspace.org/mockschema', namespace: 'http://dspace.org/mockschema',
}, },
]; ] as MetadataSchema[];
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();
const configurationDataService = jasmine.createSpyObj('configurationDataService', { const configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
@@ -109,6 +93,10 @@ describe('MetadataRegistryComponent', () => {
); );
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
paginationService = new PaginationServiceStub();
registryService = new RegistryServiceStub();
spyOn(registryService, 'getMetadataSchemas').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)));
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
CommonModule, CommonModule,
@@ -120,7 +108,7 @@ describe('MetadataRegistryComponent', () => {
EnumKeysPipe, EnumKeysPipe,
], ],
providers: [ providers: [
{ provide: RegistryService, useValue: registryServiceStub }, { provide: RegistryService, useValue: registryService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ {
@@ -190,7 +178,7 @@ describe('MetadataRegistryComponent', () => {
})); }));
it('should cancel editing the selected schema when clicked again', waitForAsync(() => { 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'); spyOn(registryService, 'cancelEditMetadataSchema');
row.click(); row.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -205,7 +193,7 @@ describe('MetadataRegistryComponent', () => {
beforeEach(() => { beforeEach(() => {
spyOn(registryService, 'deleteMetadataSchema').and.callThrough(); 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(); comp.deleteSchemas();
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@@ -1,25 +1,21 @@
import { import {
AsyncPipe, AsyncPipe,
NgClass, NgClass,
NgForOf,
NgIf,
} from '@angular/common'; } from '@angular/common';
import { import {
Component, Component,
OnDestroy, OnDestroy,
OnInit,
} from '@angular/core'; } from '@angular/core';
import { import { RouterLink } from '@angular/router';
Router,
RouterLink,
} from '@angular/router';
import { import {
TranslateModule, TranslateModule,
TranslateService, TranslateService,
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest as observableCombineLatest,
Observable, Observable,
Subscription,
zip, zip,
} from 'rxjs'; } from 'rxjs';
import { import {
@@ -36,7 +32,6 @@ import { PaginationService } from '../../../core/pagination/pagination.service';
import { RegistryService } from '../../../core/registry/registry.service'; import { RegistryService } from '../../../core/registry/registry.service';
import { NoContent } from '../../../core/shared/NoContent.model'; import { NoContent } from '../../../core/shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { toFindListOptions } from '../../../shared/pagination/pagination.utils'; import { toFindListOptions } from '../../../shared/pagination/pagination.utils';
@@ -52,8 +47,6 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch
TranslateModule, TranslateModule,
AsyncPipe, AsyncPipe,
PaginationComponent, PaginationComponent,
NgIf,
NgForOf,
NgClass, NgClass,
RouterLink, RouterLink,
], ],
@@ -63,13 +56,23 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch
* A component used for managing all existing metadata schemas within the repository. * A component used for managing all existing metadata schemas within the repository.
* The admin can create, edit or delete metadata schemas here. * 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 * A list of all the current metadata schemas within the repository
*/ */
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>; 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 * Pagination config used to display the list of metadata schemas
*/ */
@@ -79,15 +82,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); needsUpdate$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
constructor(private registryService: RegistryService, subscriptions: Subscription[] = [];
private notificationsService: NotificationsService,
private router: Router, constructor(
private paginationService: PaginationService, protected registryService: RegistryService,
private translateService: TranslateService) { 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(); this.updateSchemas();
} }
@@ -116,30 +129,13 @@ export class MetadataRegistryComponent implements OnDestroy {
* @param schema * @param schema
*/ */
editSchema(schema: MetadataSchema) { editSchema(schema: MetadataSchema) {
this.getActiveSchema().pipe(take(1)).subscribe((activeSchema) => { this.subscriptions.push(this.activeMetadataSchema$.pipe(take(1)).subscribe((activeSchema: MetadataSchema) => {
if (schema === activeSchema) { if (schema === activeSchema) {
this.registryService.cancelEditMetadataSchema(); this.registryService.cancelEditMetadataSchema();
} else { } else {
this.registryService.editMetadataSchema(schema); 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 +149,25 @@ export class MetadataRegistryComponent implements OnDestroy {
this.registryService.deselectMetadataSchema(schema); 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 * Delete all the selected metadata schemas
*/ */
deleteSchemas() { deleteSchemas() {
this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( this.subscriptions.push(this.selectedMetadataSchemaIDs$.pipe(
(schemas) => { take(1),
const tasks$ = []; switchMap((schemaIDs: number[]) => zip(schemaIDs.map((schemaID: number) => this.registryService.deleteMetadataSchema(schemaID).pipe(getFirstCompletedRemoteData())))),
for (const schema of schemas) { ).subscribe((responses: RemoteData<NoContent>[]) => {
if (hasValue(schema.id)) { const successResponses: RemoteData<NoContent>[] = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
tasks$.push(this.registryService.deleteMetadataSchema(schema.id).pipe(getFirstCompletedRemoteData())); const failedResponses: RemoteData<NoContent>[] = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
} if (successResponses.length > 0) {
} this.showNotification(true, successResponses.length);
zip(...tasks$).subscribe((responses: RemoteData<NoContent>[]) => { }
const successResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded); if (failedResponses.length > 0) {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed); this.showNotification(false, failedResponses.length);
if (successResponses.length > 0) { }
this.showNotification(true, successResponses.length); this.registryService.deselectAllMetadataSchema();
} this.registryService.cancelEditMetadataSchema();
if (failedResponses.length > 0) { }));
this.showNotification(false, failedResponses.length);
}
this.registryService.deselectAllMetadataSchema();
this.registryService.cancelEditMetadataSchema();
});
},
);
} }
/** /**
@@ -199,20 +178,20 @@ export class MetadataRegistryComponent implements OnDestroy {
showNotification(success: boolean, amount: number) { showNotification(success: boolean, amount: number) {
const prefix = 'admin.registries.schema.notification'; const prefix = 'admin.registries.schema.notification';
const suffix = success ? 'success' : 'failure'; const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest(
this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), const head: string = this.translateService.instant(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`);
this.translateService.get(`${prefix}.deleted.${suffix}`, { amount: amount }), const content: string = this.translateService.instant(`${prefix}.deleted.${suffix}`, { amount: amount });
);
messages.subscribe(([head, content]) => { if (success) {
if (success) { this.notificationsService.success(head, content);
this.notificationsService.success(head, content); } else {
} else { this.notificationsService.error(head, content);
this.notificationsService.error(head, content); }
}
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -1,18 +1,16 @@
<div *ngIf="registryService.getActiveMetadataSchema() | async; then editheader; else createHeader"></div> @if (activeMetadataSchema$ | async) {
<h2>{{messagePrefix + '.edit' | translate}}</h2>
} @else {
<h2>{{messagePrefix + '.create' | translate}}</h2>
}
<ng-template #createHeader>
<h2>{{messagePrefix + '.create' | translate}}</h2>
</ng-template>
<ng-template #editheader>
<h2>{{messagePrefix + '.edit' | translate}}</h2>
</ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"
[formModel]="formModel" [formModel]="formModel"
[formGroup]="formGroup" [formGroup]="formGroup"
[formLayout]="formLayout" [formLayout]="formLayout"
(cancel)="onCancel()" (cancel)="onCancel()"
(submitForm)="onSubmit()"> (submitForm)="onSubmit()">
</ds-form> </ds-form>

View File

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

View File

@@ -1,7 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
EventEmitter, EventEmitter,
@@ -21,13 +18,13 @@ import {
TranslateService, TranslateService,
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { import {
combineLatest,
Observable, Observable,
Subscription,
} from 'rxjs'; } from 'rxjs';
import { import {
map,
switchMap, switchMap,
take, take,
tap,
} from 'rxjs/operators'; } from 'rxjs/operators';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
@@ -39,7 +36,6 @@ import { FormComponent } from '../../../../shared/form/form.component';
selector: 'ds-metadata-schema-form', selector: 'ds-metadata-schema-form',
templateUrl: './metadata-schema-form.component.html', templateUrl: './metadata-schema-form.component.html',
imports: [ imports: [
NgIf,
AsyncPipe, AsyncPipe,
TranslateModule, TranslateModule,
FormComponent, FormComponent,
@@ -102,64 +98,71 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
*/ */
@Output() submitForm: EventEmitter<any> = new EventEmitter(); @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() { ngOnInit() {
combineLatest([ this.name = new DynamicInputModel({
this.translateService.get(`${this.messagePrefix}.name`), id: 'name',
this.translateService.get(`${this.messagePrefix}.namespace`), label: this.translateService.instant(`${this.messagePrefix}.name`),
]).subscribe(([name, namespace]) => { name: 'name',
this.name = new DynamicInputModel({ validators: {
id: 'name', required: null,
label: name, pattern: '^[^. ,]*$',
name: 'name', maxLength: 32,
validators: { },
required: null, required: true,
pattern: '^[^. ,]*$', errorMessages: {
maxLength: 32, pattern: 'error.validation.metadata.name.invalid-pattern',
}, maxLength: 'error.validation.metadata.name.max-length',
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.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 +179,29 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
* Emit the updated/created schema using the EventEmitter submitForm * Emit the updated/created schema using the EventEmitter submitForm
*/ */
onSubmit(): void { onSubmit(): void {
this.registryService this.activeMetadataSchema$.pipe(
.getActiveMetadataSchema() take(1),
.pipe( switchMap((schema: MetadataSchema) => {
take(1), const metadataValues = {
switchMap((schema: MetadataSchema) => { prefix: this.name.value,
const metadataValues = { namespace: this.namespace.value,
prefix: this.name.value, };
namespace: this.namespace.value, if (schema == null) {
}; return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), metadataValues));
} else {
let createOrUpdate$: Observable<MetadataSchema>; return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, {
namespace: metadataValues.namespace,
if (schema == null) { }));
createOrUpdate$ = }
this.registryService.createOrUpdateMetadataSchema( }),
Object.assign(new MetadataSchema(), metadataValues), switchMap((updatedOrCreatedSchema: MetadataSchema) => this.registryService.clearMetadataSchemaRequests().pipe(
); map(() => updatedOrCreatedSchema),
} else { )),
const updatedSchema = Object.assign( ).subscribe((updatedOrCreatedSchema: MetadataSchema) => {
new MetadataSchema(), this.submitForm.emit(updatedOrCreatedSchema);
schema, this.clearFields();
{ this.registryService.cancelEditMetadataSchema();
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();
});
} }
/** /**
@@ -233,5 +217,6 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.onCancel(); this.onCancel();
this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -1,18 +1,16 @@
<div *ngIf="registryService.getActiveMetadataField() | async; then editheader; else createHeader"></div> @if (registryService.getActiveMetadataField() | async) {
<h2>{{messagePrefix + '.edit' | translate}}</h2>
} @else {
<h2>{{messagePrefix + '.create' | translate}}</h2>
}
<ng-template #createHeader>
<h2>{{messagePrefix + '.create' | translate}}</h2>
</ng-template>
<ng-template #editheader>
<h2>{{messagePrefix + '.edit' | translate}}</h2>
</ng-template>
<ds-form [formId]="formId" <ds-form [formId]="formId"
[formModel]="formModel" [formModel]="formModel"
[formLayout]="formLayout" [formLayout]="formLayout"
[formGroup]="formGroup" [formGroup]="formGroup"
(cancel)="onCancel()" (cancel)="onCancel()"
(submit)="onSubmit()"> (submit)="onSubmit()">
</ds-form> </ds-form>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
inject, inject,
@@ -17,13 +17,15 @@ import { RegistryService } from '../../../../core/registry/registry.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { FormComponent } from '../../../../shared/form/form.component'; import { FormComponent } from '../../../../shared/form/form.component';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; 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 { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe';
import { MetadataFieldFormComponent } from './metadata-field-form.component'; import { MetadataFieldFormComponent } from './metadata-field-form.component';
describe('MetadataFieldFormComponent', () => { describe('MetadataFieldFormComponent', () => {
let component: MetadataFieldFormComponent; let component: MetadataFieldFormComponent;
let fixture: ComponentFixture<MetadataFieldFormComponent>; let fixture: ComponentFixture<MetadataFieldFormComponent>;
let registryService: RegistryService;
let registryService: RegistryServiceStub;
const metadataSchema = Object.assign(new MetadataSchema(), { const metadataSchema = Object.assign(new MetadataSchema(), {
id: 1, id: 1,
@@ -31,37 +33,16 @@ describe('MetadataFieldFormComponent', () => {
prefix: 'fake', 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(() => { beforeEach(waitForAsync(() => {
registryService = new RegistryServiceStub();
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataFieldFormComponent, EnumKeysPipe], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataFieldFormComponent, EnumKeysPipe],
providers: [ providers: [
{ provide: RegistryService, useValue: registryServiceStub }, { provide: RegistryService, useValue: registryService },
{ provide: FormBuilderService, useValue: getMockFormBuilderService() }, { provide: FormBuilderService, useValue: getMockFormBuilderService() },
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
}) })
.overrideComponent(MetadataFieldFormComponent, { .overrideComponent(MetadataFieldFormComponent, {
remove: { imports: [FormComponent] }, remove: { imports: [FormComponent] },

View File

@@ -1,7 +1,4 @@
import { import { AsyncPipe } from '@angular/common';
AsyncPipe,
NgIf,
} from '@angular/common';
import { import {
Component, Component,
EventEmitter, EventEmitter,
@@ -35,7 +32,6 @@ import { FormComponent } from '../../../../shared/form/form.component';
selector: 'ds-metadata-field-form', selector: 'ds-metadata-field-form',
templateUrl: './metadata-field-form.component.html', templateUrl: './metadata-field-form.component.html',
imports: [ imports: [
NgIf,
FormComponent, FormComponent,
TranslateModule, TranslateModule,
AsyncPipe, AsyncPipe,

View File

@@ -8,52 +8,59 @@
<ds-metadata-field-form <ds-metadata-field-form
[metadataSchema]="schema" [metadataSchema]="schema"
(submitForm)="forceUpdateFields()"></ds-metadata-field-form> (submitForm)="forceUpdateFields()"></ds-metadata-field-form>
<h2>{{'admin.registries.schema.fields.head' | translate}}</h2> <h2>{{'admin.registries.schema.fields.head' | translate}}</h2>
<ng-container *ngVar="(metadataFields$ | async)?.payload as fields"> <ng-container *ngVar="(metadataFields$ | async)?.payload as fields">
<ds-pagination @if (fields?.totalElements > 0) {
*ngIf="fields?.totalElements > 0" <ds-pagination
[paginationOptions]="config" [paginationOptions]="config"
[collectionSize]="fields?.totalElements" [collectionSize]="fields?.totalElements"
[hideGear]="false" [hideGear]="false"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
<div class="table-responsive"> <div class="table-responsive">
<table id="metadata-fields" class="table table-striped table-hover"> <table id="metadata-fields" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th><span class="sr-only">{{'admin.registries.schema.fields.table.selected' | translate}}</span></th> <th><span class="sr-only">{{'admin.registries.schema.fields.table.selected' | translate}}</span></th>
<th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th> <th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let field of fields?.page" @for (field of fields?.page; track field) {
[ngClass]="{'table-primary' : isActive(field) | async}"> <tr
<td *ngVar="(isSelected(field) | async) as selected"> [ngClass]="{'table-primary' : (activeField$ | async)?.id === field.id}">
<input type="checkbox" <td *ngVar="(selectedMetadataFieldIDs$ | async)?.includes(field.id) as selected">
[attr.aria-label]="(selected ? 'admin.registries.schema.fields.deselect' : 'admin.registries.schema.fields.select') | translate" <input type="checkbox"
[checked]="selected" [attr.aria-label]="(selected ? 'admin.registries.schema.fields.deselect' : 'admin.registries.schema.fields.select') | translate"
(change)="selectMetadataField(field, $event)"> [checked]="selected"
</td> (change)="selectMetadataField(field, $event)">
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td> </td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td> <td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td>
</tr> <td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tbody> </tr>
</table> }
</div> </tbody>
</ds-pagination> </table>
</div>
</ds-pagination>
}
<div *ngIf="fields?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert"> @if (fields?.totalElements === 0) {
{{'admin.registries.schema.fields.no-items' | translate}} <div class="alert alert-info w-100 mb-2" role="alert">
</div> {{'admin.registries.schema.fields.no-items' | translate}}
</div>
}
<div> <div>
<button [routerLink]="['/admin/registries/metadata']" class="btn btn-primary">{{'admin.registries.schema.return' | translate}}</button> <button [routerLink]="['/admin/registries/metadata']" class="btn btn-primary">{{'admin.registries.schema.return' | translate}}</button>
<button *ngIf="fields?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}</button> @if (fields?.page?.length > 0) {
<button type="submit" class="btn btn-danger float-end" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}</button>
}
</div> </div>
</ng-container> </ng-container>

View File

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

View File

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

View File

@@ -1,64 +1,72 @@
<div class="container"> <div class="container">
<div class="metadata-registry row"> <div class="metadata-registry row">
<div class="col-12"> <div class="col-12">
<h1 id="header" class="border-bottom pb-2">{{ "admin.reports.collections.head" | translate }}</h1> <h1 id="header" class="border-bottom pb-2">{{ "admin.reports.collections.head" | translate }}</h1>
<div id="metadatadiv"> <div id="metadatadiv">
<ngb-accordion [closeOthers]="true" activeIds="filters" #acc="ngbAccordion"> <ngb-accordion [closeOthers]="true" activeIds="filters" #acc="ngbAccordion">
<ngb-panel id="filters"> <ngb-panel id="filters">
<ng-template ngbPanelTitle> <ng-template ngbPanelTitle>
{{ "admin.reports.commons.filters" | translate }} {{ "admin.reports.commons.filters" | translate }}
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<span class="col-3"></span> <span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{ "admin.reports.button.show-collections" | translate }}</button> <button class="btn btn-primary mt-1 col-6" (click)="submit()">{{ "admin.reports.button.show-collections" | translate }}</button>
</div> </div>
<ds-filters [filtersForm]="filtersFormGroup()"></ds-filters> <ds-filters [filtersForm]="filtersFormGroup()"></ds-filters>
<div class="row"> <div class="row">
<span class="col-3"></span> <span class="col-3"></span>
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{ "admin.reports.button.show-collections" | translate }}</button> <button class="btn btn-primary mt-1 col-6" (click)="submit()">{{ "admin.reports.button.show-collections" | translate }}</button>
</div> </div>
</div> </div>
</ng-template> </ng-template>
</ngb-panel> </ngb-panel>
<ngb-panel id="collections"> <ngb-panel id="collections">
<ng-template ngbPanelTitle> <ng-template ngbPanelTitle>
{{ "admin.reports.collections.collections-report" | translate }} {{ "admin.reports.collections.collections-report" | translate }}
</ng-template> </ng-template>
<ng-template ngbPanelContent> <ng-template ngbPanelContent>
<table id="table" class="table table-striped"> <table id="table" class="table table-striped">
<thead> <thead>
<tr class="header"> <tr class="header">
<th rowspan="2">{{ "admin.reports.collections.community" | translate }}</th> <th rowspan="2">{{ "admin.reports.collections.community" | translate }}</th>
<th rowspan="2">{{ "admin.reports.collections.collection" | translate }}</th> <th rowspan="2">{{ "admin.reports.collections.collection" | translate }}</th>
<th>{{ "admin.reports.collections.nb_items" | translate }}</th> <th>{{ "admin.reports.collections.nb_items" | translate }}</th>
<th>{{ "admin.reports.collections.match_all_selected_filters" | translate }}</th> <th>{{ "admin.reports.collections.match_all_selected_filters" | translate }}</th>
<th *ngFor="let filter of results.summary.values | keyvalue">{{ ("admin.reports.commons.filters." + getGroup(filter.key) + "." + filter.key) | translate }}</th> @for (filter of results.summary.values | keyvalue; track filter) {
</tr> <th>{{ ("admin.reports.commons.filters." + getGroup(filter.key) + "." + filter.key) | translate }}</th>
<tr class="header"> }
<th class="num">{{ results.summary.nbTotalItems }}</th> </tr>
<th class="num">{{ results.summary.allFiltersValue }}</th> <tr class="header">
<th class="num" *ngFor="let filter of results.summary.values | keyvalue">{{ filter.value }}</th> <th class="num">{{ results.summary.nbTotalItems }}</th>
</tr> <th class="num">{{ results.summary.allFiltersValue }}</th>
</thead> @for (filter of results.summary.values | keyvalue; track filter) {
<tbody> <th class="num">{{ filter.value }}</th>
<tr *ngFor="let coll of results.collections"> }
<td><a href="/handle/{{ coll.communityHandle }}" rel="noopener noreferrer" target="_blank">{{ coll.communityLabel }}</a></td> </tr>
<td><a href="/handle/{{ coll.handle }}" rel="noopener noreferrer" target="_blank">{{ coll.label }}</a></td> </thead>
<td class="num">{{ coll.nbTotalItems }}</td> <tbody>
<td class="num">{{ coll.allFiltersValue }}</td> @for (coll of results.collections; track coll) {
<td class="num" *ngFor="let filter of results.summary.values | keyvalue">{{ coll.values[filter.key] || 0 }}</td> <tr>
</tr> <td><a href="/handle/{{ coll.communityHandle }}" rel="noopener noreferrer" target="_blank">{{ coll.communityLabel }}</a></td>
</tbody> <td><a href="/handle/{{ coll.handle }}" rel="noopener noreferrer" target="_blank">{{ coll.label }}</a></td>
</table> <td class="num">{{ coll.nbTotalItems }}</td>
</ng-template> <td class="num">{{ coll.allFiltersValue }}</td>
</ngb-panel> @for (filter of results.summary.values | keyvalue; track filter) {
</ngb-accordion> <td class="num">{{ coll.values[filter.key] || 0 }}</td>
</div> }
</tr>
}
</tbody>
</table>
</ng-template>
</ngb-panel>
</ngb-accordion>
</div>
</div>
</div> </div>
</div>
</div> </div>

View File

@@ -1,4 +1,8 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'; import {
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
@@ -39,22 +43,21 @@ describe('FiltersComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ schemas: [NO_ERRORS_SCHEMA],
NgbAccordionModule, imports: [NgbAccordionModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateLoaderMock, useClass: TranslateLoaderMock,
}, },
}), }),
HttpClientTestingModule, FilteredCollectionsComponent],
FilteredCollectionsComponent,
],
providers: [ providers: [
FormBuilder, FormBuilder,
DspaceRestService, DspaceRestService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
], ],
schemas: [NO_ERRORS_SCHEMA],
}); });
})); }));

View File

@@ -1,7 +1,4 @@
import { import { KeyValuePipe } from '@angular/common';
KeyValuePipe,
NgForOf,
} from '@angular/common';
import { import {
Component, Component,
OnInit, OnInit,
@@ -37,7 +34,6 @@ import { FilteredCollections } from './filtered-collections.model';
NgbAccordionModule, NgbAccordionModule,
FiltersComponent, FiltersComponent,
KeyValuePipe, KeyValuePipe,
NgForOf,
], ],
standalone: true, standalone: true,
}) })

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