Merge remote-tracking branch 'upstream/main' into config-default-comcol-tab

This commit is contained in:
Abel Gómez
2025-03-13 20:01:56 +01:00
1382 changed files with 69976 additions and 34598 deletions

View File

@@ -25,4 +25,6 @@ npm-debug.log.*
# Webpack files
webpack.records.json
package-lock.json
# Yarn no longer used
yarn.lock

View File

@@ -165,6 +165,7 @@
"@angular-eslint/no-output-native": "warn",
"@angular-eslint/no-output-on-prefix": "warn",
"@angular-eslint/no-conflicting-lifecycle": "warn",
"@angular-eslint/use-lifecycle-interface": "error",
"@typescript-eslint/no-inferrable-types":[
"error",
@@ -292,7 +293,9 @@
],
"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"
}
},
{

View File

@@ -7,16 +7,16 @@ assignees: ''
---
**Describe the bug**
## Describe the bug
A clear and concise description of what the bug is. Include the version(s) of DSpace where you've seen this problem & what *web browser* you were using. Link to examples if they are public.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Do this
2. Then this...
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Related work**
## Related work
Link to any related tickets or PRs here.

View File

@@ -7,14 +7,14 @@ assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Is your feature request related to a problem? Please describe.
A clear and concise description of what the problem or use case is. For example, I'm always frustrated when [...]
**Describe the solution you'd like**
## Describe the solution you'd like
A clear and concise description of what you want to happen.
**Describe alternatives or workarounds you've considered**
## Describe alternatives or workarounds you've considered
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
## Additional information
Add any other information, related tickets or screenshots about the feature request here.

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

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

View File

@@ -1,7 +1,7 @@
## References
_Add references/links to any related issues or PRs. These may include:_
* Fixes #`issue-number` (if this fixes an issue ticket)
* Requires DSpace/DSpace#`pr-number` (if a REST API PR is required to test this)
* Fixes #issue-number (if this fixes an issue ticket)
* Requires DSpace/DSpace#pr-number (if a REST API PR is required to test this)
## Description
Short summary of changes (1-2 sentences).
@@ -16,13 +16,18 @@ List of changes in this PR:
**Include guidance for how to test or review your PR.** This may include: steps to reproduce a bug, screenshots or description of a new feature, or reasons behind specific changes.
## Checklist
_This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_
_This checklist provides a reminder of what we are going to look for when reviewing your PR. You do not need to complete this checklist prior creating your PR (draft PRs are always welcome).
However, reviewers may request that you complete any actions in this list if you have not done so. If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_
- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
- [ ] My PR passes [ESLint](https://eslint.org/) validation using `yarn lint`
- [ ] My PR doesn't introduce circular dependencies (verified via `yarn check-circ-deps`)
- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] My PR is **created against the `main` branch** of code (unless it is a backport or is fixing an issue specific to an older branch).
- [ ] My PR is **small in size** (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
- [ ] My PR **passes [ESLint](https://eslint.org/)** validation using `npm run lint`
- [ ] My PR **doesn't introduce circular dependencies** (verified via `npm run check-circ-deps`)
- [ ] My PR **includes [TypeDoc](https://typedoc.org/) comments** for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR **passes all specs/tests and includes new/updated specs or tests** based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] My PR **aligns with [Accessibility guidelines](https://wiki.lyrasis.org/display/DSDOC8x/Accessibility)** if it makes changes to the user interface.
- [ ] My PR **uses i18n (internationalization) keys** instead of hardcoded English text, to allow for translations.
- [ ] My PR **includes details on how to test it**. I've provided clear instructions to reviewers on how to successfully test this fix or feature.
- [ ] If my PR includes new libraries/dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
- [ ] If my PR includes new features or configurations, I've provided basic technical documentation in the PR itself.
- [ ] If my PR fixes an issue ticket, I've [linked them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).

View File

@@ -8,6 +8,7 @@ on: [push, pull_request]
permissions:
contents: read # to fetch code (actions/checkout)
packages: read # to fetch private images from GitHub Container Registry (GHCR)
jobs:
tests:
@@ -35,6 +36,9 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096'
# Project name to use when running "docker compose" prior to e2e tests
COMPOSE_PROJECT_NAME: 'ci'
# Docker Registry to use for Docker compose scripts below.
# We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub.
DOCKER_REGISTRY: ghcr.io
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
@@ -69,39 +73,39 @@ jobs:
fi
google-chrome --version
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
- name: Get Yarn cache directory
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache Yarn dependencies
# https://github.com/actions/cache/blob/main/examples.md#node---npm
- name: Get NPM cache directory
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache NPM dependencies
uses: actions/cache@v4
with:
# Cache entire Yarn cache directory (see previous step)
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
# Cache key is hash of yarn.lock. Therefore changes to yarn.lock will invalidate cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
# Cache entire NPM cache directory (see previous step)
path: ${{ steps.npm-cache-dir.outputs.dir }}
# Cache key is hash of package-lock.json. Therefore changes to package-lock.json will invalidate cache
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install Yarn dependencies
run: yarn install --frozen-lockfile
- name: Install NPM dependencies
run: npm clean-install
- name: Build lint plugins
run: yarn run build:lint
run: npm run build:lint
- name: Run lint plugin tests
run: yarn run test:lint:nobuild
run: npm run test:lint:nobuild
- name: Run lint
run: yarn run lint:nobuild --quiet
run: npm run lint:nobuild -- --quiet
- name: Check for circular dependencies
run: yarn run check-circ-deps
run: npm run check-circ-deps
- name: Run build
run: yarn run build:prod
run: npm run build:prod
- name: Run specs (unit tests)
run: yarn run test:headless
run: npm run test:headless
# Upload code coverage report to artifact (for one version of Node only),
# so that it can be shared with the 'codecov' job (see below)
@@ -114,6 +118,14 @@ jobs:
path: 'coverage/dspace-angular/lcov.info'
retention-days: 14
# Login to our Docker registry, so that we can access private Docker images using "docker compose" below.
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Using "docker compose" start backend using CI configuration
# and load assetstore from a cached copy
- name: Start DSpace REST Backend via Docker (for e2e tests)
@@ -131,7 +143,7 @@ jobs:
# Run tests in Chrome, headless mode (default)
browser: chrome
# Start app before running tests (will be stopped automatically after tests finish)
start: yarn run serve:ssr
start: npm run serve:ssr
# Wait for backend & frontend to be available
# NOTE: We use the 'sites' REST endpoint to also ensure the database is ready
wait-on: http://127.0.0.1:8080/server/api/core/sites, http://127.0.0.1:4000
@@ -167,7 +179,7 @@ jobs:
# Start up the app with SSR enabled (run in background)
- name: Start app in SSR (server-side rendering) mode
run: |
nohup yarn run serve:ssr &
nohup npm run serve:ssr &
printf 'Waiting for app to start'
until curl --output /dev/null --silent --head --fail http://127.0.0.1:4000/home; do
printf '.'

View File

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

4
.gitignore vendored
View File

@@ -28,12 +28,12 @@ webpack.records.json
morgan.log
# Yarn no longer used
yarn.lock
yarn-error.log
*.css
package-lock.json
.java-version
.env

View File

@@ -10,7 +10,7 @@ DSpace is a community built and supported project. We do not have a centralized
## Contribute new code via a Pull Request
We accept [GitHub Pull Requests (PRs)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) at any time from anyone.
Contributors to each release are recognized in our [Release Notes](https://wiki.lyrasis.org/display/DSDOC7x/Release+Notes).
Contributors to each release are recognized in our [Release Notes](https://wiki.lyrasis.org/display/DSDOC8x/Release+Notes).
Code Contribution Checklist
- [ ] PRs _should_ be smaller in size (ideally less than 1,000 lines of code, not including comments & tests)
@@ -18,6 +18,9 @@ Code Contribution Checklist
- [ ] PRs **must** not introduce circular dependencies (verified via `yarn check-circ-deps`)
- [ ] PRs **must** include [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. Large or complex private methods should also have TypeDoc.
- [ ] PRs **must** pass all automated pecs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] User interface changes **must** align with [Accessibility guidelines](https://wiki.lyrasis.org/display/DSDOC8x/Accessibility)
- [ ] PRs **must** use i18n (internationalization) keys instead of hardcoded English text, to allow for translations.
- [ ] Details on how to test the PR **must** be provided. Reviewers must be aware of any steps they need to take to successfully test your fix or feature.
- [ ] If a PR includes new libraries/dependencies (in `package.json`), then their software licenses **must** align with the [DSpace BSD License](https://github.com/DSpace/dspace-angular/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
- [ ] Basic technical documentation _should_ be provided for any new features or configuration, either in the PR itself or in the DSpace Wiki documentation.
- [ ] If a PR fixes an issue ticket, please [link them together](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
@@ -26,7 +29,7 @@ Additional details on the code contribution process can be found in our [Code Co
## Contribute documentation
DSpace Documentation is a collaborative effort in a shared Wiki. The latest documentation is at https://wiki.lyrasis.org/display/DSDOC7x
DSpace Documentation is a collaborative effort in a shared Wiki. The latest documentation is at https://wiki.lyrasis.org/display/DSDOC
If you find areas of the DSpace Documentation which you wish to improve, please request a Wiki account by emailing wikihelp@lyrasis.org.
Once you have an account setup, contact @tdonohue (via [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) or email) for access to edit our Documentation.
@@ -34,7 +37,7 @@ Once you have an account setup, contact @tdonohue (via [Slack](https://wiki.lyra
## Help others on mailing lists or Slack
DSpace has our own [Slack](https://wiki.lyrasis.org/display/DSPACE/Slack) community and [Mailing Lists](https://wiki.lyrasis.org/display/DSPACE/Mailing+Lists) where discussions take place and questions are answered.
Anyone is welcome to join and help others. We just ask you to follow our [Code of Conduct](https://www.lyrasis.org/about/Pages/Code-of-Conduct.aspx) (adopted via LYRASIS).
Anyone is welcome to join and help others. We just ask you to follow our [Code of Conduct](https://www.lyrasis.org/about/Pages/Code-of-Conduct.aspx) (adopted via Lyrasis).
## Join a working or interest group
@@ -42,5 +45,5 @@ Most of the work in building/improving DSpace comes via [Working Groups](https:/
All working/interest groups are open to anyone to join and participate. A few key groups to be aware of include:
* [DSpace 7 Working Group](https://wiki.lyrasis.org/display/DSPACE/DSpace+7+Working+Group) - This is the main (mostly volunteer) development team. We meet weekly to review our current development [project board](https://github.com/orgs/DSpace/projects), assigning tickets and/or PRs.
* [DSpace Community Advisory Team (DCAT)](https://wiki.lyrasis.org/display/cmtygp/DSpace+Community+Advisory+Team) - This is an interest group for repository managers/administrators. We meet monthly to discuss DSpace, share tips & provide feedback back to developers.
* [DSpace Developer Team](https://wiki.lyrasis.org/display/DSPACE/Developer+Meetings) - This is the primary, volunteer development team. We meet weekly to review our current development [project board](https://github.com/orgs/DSpace/projects), assigning tickets and/or PRs. This is also were discussions of the next release or major issues occur. Anyone is welcome to attend.
* [DSpace Community Advisory Team (DCAT)](https://wiki.lyrasis.org/display/cmtygp/DSpace+Community+Advisory+Team) - This is an interest group for repository managers/administrators. We meet monthly to discuss DSpace, share tips & provide feedback back to developers. Anyone is welcome to attend.

View File

@@ -1,7 +1,7 @@
# This image will be published as dspace/dspace-angular
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
FROM node:18-alpine
FROM docker.io/node:18-alpine
# Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64
@@ -11,9 +11,7 @@ WORKDIR /app
ADD . /app/
EXPOSE 4000
# We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
# See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000
RUN npm install
# When running in dev mode, 4GB of memory is required to build & launch the app.
# This default setting can be overridden as needed in your shell, via an env file or in docker-compose.
@@ -24,5 +22,5 @@ ENV NODE_OPTIONS="--max_old_space_size=4096"
# Listen / accept connections from all IP addresses.
# NOTE: At this time it is only possible to run Docker container in Production mode
# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
ENV NODE_ENV development
CMD yarn serve --host 0.0.0.0
ENV NODE_ENV=development
CMD npm run serve -- --host 0.0.0.0

View File

@@ -4,18 +4,18 @@
# Test build:
# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist .
FROM node:18-alpine as build
FROM docker.io/node:18-alpine AS build
# Ensure Python and other build tools are available
# These are needed to install some node modules, especially on linux/arm64
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --network-timeout 300000
COPY package.json package-lock.json ./
RUN npm install
ADD . /app/
RUN yarn build:prod
RUN npm run build:prod
FROM node:18-alpine
RUN npm install --global pm2
@@ -26,6 +26,6 @@ COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
WORKDIR /app
USER node
ENV NODE_ENV production
ENV NODE_ENV=production
EXPOSE 4000
CMD pm2-runtime start dspace-ui.json --json

View File

@@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace
Quick start
-----------
**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`**
**Ensure you're running [Node](https://nodejs.org) `v18.x` or `v20.x`, [npm](https://www.npmjs.com/) >= `v10.x`**
```bash
# clone the repo
@@ -45,10 +45,10 @@ git clone https://github.com/DSpace/dspace-angular.git
cd dspace-angular
# install the local dependencies
yarn install
npm install
# start the server
yarn start
npm start
```
Then go to [http://localhost:4000](http://localhost:4000) in your browser
@@ -77,7 +77,7 @@ Table of Contents
- [Recommended Editors/IDEs](#recommended-editorsides)
- [Collaborating](#collaborating)
- [File Structure](#file-structure)
- [Managing Dependencies (via yarn)](#managing-dependencies-via-yarn)
- [Managing Dependencies (via npm)](#managing-dependencies-via-npm)
- [Frequently asked questions](#frequently-asked-questions)
- [License](#license)
@@ -89,15 +89,15 @@ You can find more information on the technologies used in this project (Angular.
Requirements
------------
- [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com)
- Ensure you're running node `v16.x` or `v18.x` and yarn == `v1.x`
- [Node.js](https://nodejs.org)
- 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.
Installing
----------
- `yarn install` to install the local dependencies
- `npm install` to install the local dependencies
### Configuring
@@ -202,7 +202,7 @@ import { environment } from '../environment.ts';
Running the app
---------------
After you have installed all dependencies you can now run the app. Run `yarn run start:dev` to start a local server which will watch for changes, rebuild the code, and reload the server for you. You can visit it at `http://localhost:4000`.
After you have installed all dependencies you can now run the app. Run `npm run start:dev` to start a local server which will watch for changes, rebuild the code, and reload the server for you. You can visit it at `http://localhost:4000`.
### Running in production mode
@@ -211,20 +211,20 @@ When building for production we're using Ahead of Time (AoT) compilation. With A
To build the app for production and start the server (in one command) run:
```bash
yarn start
npm start
```
This will run the application in an instance of the Express server, which is included.
If you only want to build for production, without starting, run:
```bash
yarn run build:prod
npm run build:prod
```
This will build the application and put the result in the `dist` folder. You can copy this folder to wherever you need it for your application server. If you will be using the built-in Express server, you'll also need a copy of the `node_modules` folder tucked inside your copy of `dist`.
After building the app for production, it can be started by running:
```bash
yarn run serve:ssr
npm run serve:ssr
```
### Running the application with Docker
@@ -238,14 +238,14 @@ Cleaning
--------
```bash
# clean everything, including node_modules. You'll need to run yarn install again afterwards.
yarn run clean
# clean everything, including node_modules. You'll need to run npm install again afterwards.
npm run clean
# clean files generated by the production build (.ngfactory files, css files, etc)
yarn run clean:prod
npm run clean:prod
# cleans the distribution directory
yarn run clean:dist
npm run clean:dist
```
@@ -259,9 +259,9 @@ If you would like to contribute by testing a Pull Request (PR), here's how to do
1. Pull down the branch that the Pull Request was built from. Easy instructions for doing so can be found on the Pull Request itself.
* Next to the "Merge" button, you'll see a link that says "command line instructions".
* Click it, and follow "Step 1" of those instructions to checkout the pull down the PR branch.
2. `yarn run clean` (This resets your local dependencies to ensure you are up-to-date with this PR)
3. `yarn install` (Updates your local dependencies to those in the PR)
4. `yarn start` (Rebuilds the project, and deploys to localhost:4000, by default)
2. `npm run clean` (This resets your local dependencies to ensure you are up-to-date with this PR)
3. `npm install` (Updates your local dependencies to those in the PR)
4. `npm start` (Rebuilds the project, and deploys to localhost:4000, by default)
5. At this point, the code from the PR will be deployed to http://localhost:4000. Test it out, and ensure that it does what is described in the PR (or fixes the bug described in the ticket linked to the PR).
Once you have tested the Pull Request, please add a comment and/or approval to the PR to let us know whether you found it to be successful (or not). Thanks!
@@ -271,13 +271,13 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `npm run coverage`.
The default browser is Google Chrome.
Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
and run: `yarn test`
and run: `npm test`
If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
@@ -357,14 +357,14 @@ Some UI specific configuration documentation is also found in the [`./docs`](doc
To build the code documentation we use [TYPEDOC](http://typedoc.org). TYPEDOC is a documentation generator for TypeScript projects. It extracts information from properly formatted comments that can be written within the code files. Follow the instructions [here](http://typedoc.org/guides/doccomments/) to know how to make those comments.
Run:`yarn run docs` to produce the documentation that will be available in the 'doc' folder.
Run:`npm run docs` to produce the documentation that will be available in the 'doc' folder.
Other commands
--------------
There are many more commands in the `scripts` section of `package.json`. Most of these are executed by one of the commands mentioned above.
A command with a name that starts with `pre` or `post` will be executed automatically before or after the script with the matching name. e.g. if you type `yarn run start` the `prestart` script will run first, then the `start` script will trigger.
A command with a name that starts with `pre` or `post` will be executed automatically before or after the script with the matching name. e.g. if you type `npm run start` the `prestart` script will run first, then the `start` script will trigger.
Recommended Editors/IDEs
------------------------
@@ -456,6 +456,7 @@ dspace-angular
├── LICENSES_THIRD_PARTY *
├── nodemon.json * Nodemon (https://nodemon.io/) configuration
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
├── package-lock.json * npm lockfile (https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json)
├── postcss.config.js * PostCSS (http://postcss.org/) configuration
├── README.md * This document
├── SECURITY.md *
@@ -466,30 +467,29 @@ dspace-angular
├── tsconfig.spec.json * TypeScript config for tests
├── tsconfig.ts-node.json * TypeScript config for using ts-node directly
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
── typedoc.json * TYPEDOC configuration
└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock)
── typedoc.json * TYPEDOC configuration
```
Managing Dependencies (via yarn)
Managing Dependencies (via npm)
-------------
This project makes use of [`yarn`](https://yarnpkg.com/en/) to ensure that the exact same dependency versions are used every time you install it.
This project makes use of [`npm`](https://docs.npmjs.com/about-npm) to ensure that the exact same dependency versions are used every time you install it.
* `yarn` creates a [`yarn.lock`](https://yarnpkg.com/en/docs/yarn-lock) to track those versions. That file is updated automatically by whenever dependencies are added/updated/removed via yarn.
* **Adding new dependencies**: To install/add a new dependency (third party library), use [`yarn add`](https://yarnpkg.com/en/docs/cli/add). For example: `yarn add some-lib`.
* If you are adding a new build tool dependency (to `devDependencies`), use `yarn add some-lib --dev`
* **Upgrading existing dependencies**: To upgrade existing dependencies, you can use [`yarn upgrade`](https://yarnpkg.com/en/docs/cli/upgrade). For example: `yarn upgrade some-lib` or `yarn upgrade some-lib@version`
* **Removing dependencies**: If a dependency is no longer needed, or replaced, use [`yarn remove`](https://yarnpkg.com/en/docs/cli/remove) to remove it.
* `npm` creates a [`package-lock.json`](https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json) to track those versions. That file is updated automatically by whenever dependencies are added/updated/removed via npm.
* **Adding new dependencies**: To install/add a new dependency (third party library), use [`npm install`](https://docs.npmjs.com/cli/v10/commands/npm-install). For example: `npm install some-lib`.
* If you are adding a new build tool dependency (to `devDependencies`), use `npm install some-lib --save--dev`
* **Upgrading existing dependencies**: To upgrade existing dependencies, you can use [`npm update`](https://docs.npmjs.com/cli/v10/commands/npm-update). For example: `npm update some-lib` or `npm update some-lib@version`
* **Removing dependencies**: If a dependency is no longer needed, or replaced, use [`npm uninstall`](https://docs.npmjs.com/cli/v10/commands/npm-uninstall) to remove it.
As you can see above, using `yarn` commandline tools means that you should never need to modify the `package.json` manually. *We recommend always using `yarn` to keep dependencies updated / in sync.*
As you can see above, using `npm` commandline tools means that you should never need to modify the `package.json` manually. *We recommend always using `npm` to keep dependencies updated / in sync.*
### Adding Typings for libraries
If the library does not include typings, you can install them using yarn:
If the library does not include typings, you can install them using npm:
```bash
yarn add d3
yarn add @types/d3 --dev
npm install d3
npm install @types/d3 --save-dev
```
If the library doesn't have typings available at `@types/`, you can still use it by manually adding typings for it:
@@ -527,13 +527,13 @@ Frequently asked questions
- What are the naming conventions for Angular?
- See [the official angular style guide](https://angular.io/styleguide)
- Why is the size of my app larger in development?
- The production build uses a whole host of techniques (ahead-of-time compilation, rollup to remove unreachable code, minification, etc.) to reduce the size, that aren't used during development in the intrest of build speed.
- node-pre-gyp ERR in yarn install (Windows)
- The production build uses a whole host of techniques (ahead-of-time compilation, rollup to remove unreachable code, minification, etc.) to reduce the size, that aren't used during development in the interest of build speed.
- node-pre-gyp ERR in npm install (Windows)
- install Python x86 version between 2.5 and 3.0 on windows. See [this issue](https://github.com/AngularClass/angular2-webpack-starter/issues/626)
- How do I handle merge conflicts in yarn.lock?
- first check out the yarn.lock file from the branch you're merging in to yours: e.g. `git checkout --theirs yarn.lock`
- now run `yarn install` again. Yarn will create a new lockfile that contains both sets of changes.
- then run `git add yarn.lock` to stage the lockfile for commit
- How do I handle merge conflicts in package-lock.json?
- first check out the package-lock.json file from the branch you're merging in to yours: e.g. `git checkout --theirs package-lock.json`
- now run `npm install` again. NPM will create a new lockfile that contains both sets of changes.
- then run `git add package-lock.json` to stage the lockfile for commit
- and `git commit` to conclude the merge
Getting Help

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { testA11y } from 'cypress/support/utils';
describe('Admin Export Modals', () => {
beforeEach(() => {
// Must login as an Admin for sidebar to appear
cy.visit('/login');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('Export metadata modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-export-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-export-title"]').click();
cy.get('a[data-test="menu.section.export_metadata"]').click();
// Analyze <ds-export-metadata-selector> for accessibility
testA11y('ds-export-metadata-selector');
});
it('Export batch modal should pass accessibility tests', () => {
// Pin the sidebar open
cy.get('[data-test="sidebar-collapse-toggle"]').trigger('mouseover');
cy.get('[data-test="sidebar-collapse-toggle"]').click();
// Click on entry of menu
cy.get('[data-test="admin-menu-section-export-title"]').should('be.visible');
cy.get('[data-test="admin-menu-section-export-title"]').click();
cy.get('a[data-test="menu.section.export_batch"]').click();
// Analyze <ds-export-batch-selector> for accessibility
testA11y('ds-export-batch-selector');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Bulk Access', () => {
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/access-control/bulk-access');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should pass accessibility tests', () => {
// Page must first be visible
cy.get('ds-bulk-access').should('be.visible');
// At least one search result should be displayed
cy.get('[data-test="list-object"]').should('be.visible');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('[data-test="filter-toggle"]').click({ multiple: true });
// Analyze <ds-bulk-access> for accessibility issues
testA11y('ds-bulk-access', {
rules: {
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
// Card titles fail this test currently
'heading-order': { enabled: false },
},
} as Options);
});
});

View File

@@ -12,6 +12,13 @@ describe('Community List Page', () => {
cy.get('[data-test="expand-button"]').click({ multiple: true });
// 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
beforeEach(() => {
// Must login as an Admin to see the page
cy.visit('/health');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
describe('Health Page > Status Tab', () => {
it('should pass accessibility tests', () => {
cy.intercept('GET', '/server/actuator/health').as('status');
cy.wait('@status');
cy.get('a[data-test="health-page.status-tab"]').click();
// Page must first be visible
cy.get('ds-health-page').should('be.visible');
cy.get('ds-health-panel').should('be.visible');
// wait for all the ds-health-info-component components to be rendered
cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => {
cy.wrap($panel).find('ds-health-component').should('be.visible');
});
// Analyze <ds-health-page> for accessibility issues
testA11y('ds-health-page', {
rules: {
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
},
} as Options);
});
});
describe('Health Page > Info Tab', () => {
it('should pass accessibility tests', () => {
cy.intercept('GET', '/server/actuator/info').as('info');
cy.wait('@info');
cy.get('a[data-test="health-page.info-tab"]').click();
// Page must first be visible
cy.get('ds-health-page').should('be.visible');
cy.get('ds-health-info').should('be.visible');
// wait for all the ds-health-info-component components to be rendered
cy.get('div[role="tabpanel"]').each(($panel: HTMLDivElement) => {
cy.wrap($panel).find('ds-health-info-component').should('be.visible');
});
// Analyze <ds-health-info> for accessibility issues
testA11y('ds-health-info', {
rules: {
// All panels are accordions & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
},
} as Options);
});
});

View File

@@ -17,7 +17,7 @@ describe('Site Statistics Page', () => {
cy.visit('/statistics');
// <ds-site-statistics-page> tag must be visable
// <ds-site-statistics-page> tag must be visible
cy.get('ds-site-statistics-page').should('be.visible');
// Verify / wait until "Total Visits" table's *last* label is non-empty

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ describe('New Submission page', () => {
// Author & Subject fields have invalid "aria-multiline" attrs.
// See https://github.com/DSpace/dspace-angular/issues/1272
'aria-allowed-attr': { enabled: false },
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// All panels are accordions & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
@@ -192,7 +192,7 @@ describe('New Submission page', () => {
testA11y('ds-submission-edit',
{
rules: {
// All panels are accordians & fail "aria-required-children" and "nested-interactive".
// All panels are accordions & fail "aria-required-children" and "nested-interactive".
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
@@ -217,7 +217,7 @@ describe('New Submission page', () => {
});
// 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
// Clicking it will display a confirmation, which we will confirm with another click

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,19 +59,19 @@ A default/demo version of this image is built *automatically*.
## To refresh / pull DSpace images from Dockerhub
```
docker-compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml pull
```
## To build DSpace images using code in your branch
```
docker-compose -f docker/docker-compose.yml build
docker compose -f docker/docker-compose.yml build
```
## To start DSpace (REST and Angular) from your branch
This command provides a quick way to start both the frontend & backend from this single codebase
```
docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
docker compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
```
Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section.
@@ -86,14 +86,14 @@ _The system will be started in 2 steps. Each step shares the same docker network
From 'DSpace/DSpace' clone (build first as needed):
```
docker-compose -p d8 up -d
docker compose -p d8 up -d
```
NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md).
From 'DSpace/dspace-angular' clone (build first as needed)
```
docker-compose -p d8 -f docker/docker-compose.yml up -d
docker compose -p d8 -f docker/docker-compose.yml up -d
```
At this point, you should be able to access the UI from http://localhost:4000,
@@ -105,21 +105,21 @@ This allows you to run the Angular UI in *production* mode, pointing it at the d
(https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/).
```
docker-compose -f docker/docker-compose-dist.yml pull
docker-compose -f docker/docker-compose-dist.yml build
docker-compose -p d8 -f docker/docker-compose-dist.yml up -d
docker compose -f docker/docker-compose-dist.yml pull
docker compose -f docker/docker-compose-dist.yml build
docker compose -p d8 -f docker/docker-compose-dist.yml up -d
```
## Ingest test data from AIPDIR
Create an administrator
```
docker-compose -p d8 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
docker compose -p d8 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
```
Load content from AIP files
```
docker-compose -p d8 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
docker compose -p d8 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
```
## Alternative Ingest - Use Entities dataset
@@ -127,12 +127,12 @@ _Delete your docker volumes or use a unique project (-p) name_
Start DSpace with Database Content from a database dump
```
docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
docker compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
```
Load assetstore content and trigger a re-index of the repository
```
docker-compose -p d8 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
docker compose -p d8 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
```
## End to end testing of the REST API (runs in GitHub Actions CI).
@@ -140,5 +140,5 @@ _In this instance, only the REST api runs in Docker using the Entities dataset.
This command is only really useful for testing our Continuous Integration process.
```
docker-compose -p d8ci -f docker/docker-compose-ci.yml up -d
docker compose -p d8ci -f docker/docker-compose-ci.yml up -d
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ DSPACE_APP_CONFIG_PATH=/usr/local/dspace/config/config.yml
Configuration options can be overridden by setting environment variables.
## Nodejs server
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,10 +7,8 @@
*/
import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import { TemplateParserServices } from '@angular-eslint/utils';
import {
ESLintUtils,
TSESLint,
} from '@typescript-eslint/utils';
import { ESLintUtils } from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
import { fixture } from '../../../test/fixture';
import {
@@ -52,7 +50,7 @@ The only exception to this rule are unit tests, where we may want to use the bas
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
create(context: RuleContext<Message, unknown[]>) {
if (getFilename(context).includes('.spec.ts')) {
// skip inline templates in unit tests
return {};

View File

@@ -7,9 +7,9 @@
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
import { fixture } from '../../../test/fixture';
import {
@@ -57,7 +57,7 @@ export const info = {
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
create(context: RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {

View File

@@ -7,9 +7,9 @@
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
import { fixture } from '../../../test/fixture';
import { getComponentSelectorNode } from '../../util/angular';
@@ -58,7 +58,7 @@ Unit tests are exempt from this rule, because they may redefine components using
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
create(context: RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {

View File

@@ -7,9 +7,9 @@
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
import { fixture } from '../../../test/fixture';
import {
@@ -68,7 +68,7 @@ There are a few exceptions where the base class can still be used:
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
create(context: RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) {

View File

@@ -5,13 +5,17 @@
*
* http://www.dspace.org/license/
*/
import { TSESLint } from '@typescript-eslint/utils';
import { RuleTester } from 'eslint';
import {
InvalidTestCase,
RuleMetaData,
RuleModule,
ValidTestCase,
} from '@typescript-eslint/utils/ts-eslint';
import { EnumType } from 'typescript';
export type Meta = TSESLint.RuleMetaData<string>;
export type Valid = TSESLint.ValidTestCase<unknown[]> | RuleTester.ValidTestCase;
export type Invalid = TSESLint.InvalidTestCase<string, unknown[]> | RuleTester.InvalidTestCase;
export type Meta = RuleMetaData<string, unknown[]>;
export type Valid = ValidTestCase<unknown[]>;
export type Invalid = InvalidTestCase<string, unknown[]>;
export interface DSpaceESLintRuleInfo {
name: string;
@@ -28,7 +32,7 @@ export interface NamedTests {
export interface RuleExports {
Message: EnumType,
info: DSpaceESLintRuleInfo,
rule: TSESLint.RuleModule<string>,
rule: RuleModule<string>,
tests: NamedTests,
default: unknown,
}

View File

@@ -5,17 +5,18 @@
*
* http://www.dspace.org/license/
*/
import { TSESTree } from '@typescript-eslint/utils';
import {
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
RuleContext,
SourceCode,
} from '@typescript-eslint/utils/ts-eslint';
import {
match,
toUnixStylePath,
} from './misc';
export type AnyRuleContext = TSESLint.RuleContext<string, unknown[]>;
export type AnyRuleContext = RuleContext<string, unknown[]>;
/**
* Return the current filename based on the ESLint rule context as a Unix-style path.
@@ -27,7 +28,7 @@ export function getFilename(context: AnyRuleContext): string {
return toUnixStylePath(context.getFilename());
}
export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode {
export function getSourceCode(context: AnyRuleContext): SourceCode {
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
// eslint-disable-next-line deprecation/deprecation
return context.getSourceCode();

View File

@@ -7,7 +7,7 @@
*/
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
import { RuleTester } from 'eslint';
import { RuleTester } from '@typescript-eslint/utils/ts-eslint';
import { themeableComponents } from '../src/util/theme-support';
import {

23916
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,31 @@
{
"name": "dspace-angular",
"version": "8.0.0",
"version": "9.0.0-next",
"scripts": {
"ng": "ng",
"config:watch": "nodemon",
"test:rest": "ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
"start": "yarn run start:prod",
"start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"",
"start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"preserve": "yarn base-href",
"start": "npm run start:prod",
"start:dev": "nodemon --exec \"cross-env NODE_ENV=development npm run serve\"",
"start:prod": "npm run build:prod && cross-env NODE_ENV=production npm run serve:ssr",
"start:mirador:prod": "npm run build:mirador && npm run start:prod",
"preserve": "npm run base-href",
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"serve:ssr": "node dist/server/main",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build --configuration development",
"build:stats": "ng build --stats-json",
"build:prod": "cross-env NODE_ENV=production yarn run build:ssr",
"build:prod": "cross-env NODE_ENV=production npm run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"build:lint": "rimraf 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json",
"test": "ng test --source-map=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"test:lint": "yarn build:lint && yarn test:lint:nobuild",
"test:lint": "npm run build:lint && npm run test:lint:nobuild",
"test:lint:nobuild": "jasmine --config=lint/jasmine.json",
"lint": "yarn build:lint && yarn lint:nobuild",
"lint": "npm run build:lint && npm run lint:nobuild",
"lint:nobuild": "ng lint",
"lint-fix": "yarn build:lint && ng lint --fix=true",
"lint-fix": "npm run build:lint && ng lint --fix=true",
"docs:lint": "ts-node --project ./lint/tsconfig.json ./lint/generate-docs.ts",
"e2e": "cross-env NODE_ENV=production ng e2e",
"clean:dev:config": "rimraf src/assets/config.json",
@@ -36,8 +36,8 @@
"clean:json": "rimraf *.records.json",
"clean:node": "rimraf node_modules",
"clean:cli": "rimraf .angular/cache",
"clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json",
"clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:cli && yarn run clean:node",
"clean:prod": "npm run clean:dist && npm run clean:log && npm run clean:doc && npm run clean:coverage && npm run clean:json",
"clean": "npm run clean:prod && npm run clean:dev:config && npm run clean:cli && npm run clean:node",
"sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
@@ -46,7 +46,7 @@
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts",
"base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts",
"check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./",
"postinstall": "yarn build:lint || echo 'Skipped DSpace ESLint plugins.'"
"postinstall": "npm run build:lint || echo 'Skipped DSpace ESLint plugins.'"
},
"browser": {
"fs": false,
@@ -55,166 +55,183 @@
"https": false
},
"private": true,
"resolutions": {
"minimist": "^1.2.5",
"webdriver-manager": "^12.1.8",
"ts-node": "10.2.1"
"overrides": {
"@kolkov/ngx-gallery": {
"@angular/animations": "^18.2.12",
"@angular/common": "^18.2.12",
"@angular/core": "^18.2.12"
},
"@ng-bootstrap/ng-bootstrap": {
"@angular/common": "^18.2.12",
"@angular/core": "^18.2.12",
"@angular/forms": "^18.2.12",
"@angular/localize": "^18.2.12"
},
"@ng-dynamic-forms/core": {
"@angular/common": "^18.2.12",
"@angular/core": "^18.2.12",
"@angular/forms": "^18.2.12"
},
"@ng-dynamic-forms/ui-ng-bootstrap": {
"ngx-mask": "14.2.4",
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
"bootstrap": "^5.3"
},
"@nicky-lenaers/ngx-scroll-to": {
"@angular/common": "^18.2.12",
"@angular/core": "^18.2.12"
},
"eslint-plugin-unused-imports": {
"@typescript-eslint/eslint-plugin": "^7.2.0"
},
"ngx-infinite-scroll": {
"@angular/common": "^18.2.12",
"@angular/core": "^18.2.12"
},
"notistack": "3.0.1"
},
"dependencies": {
"@angular/animations": "^17.3.11",
"@angular/cdk": "^17.3.10",
"@angular/common": "^17.3.11",
"@angular/compiler": "^17.3.11",
"@angular/core": "^17.3.11",
"@angular/forms": "^17.3.11",
"@angular/localize": "17.3.11",
"@angular/platform-browser": "^17.3.11",
"@angular/platform-browser-dynamic": "^17.3.11",
"@angular/platform-server": "^17.3.11",
"@angular/router": "^17.3.11",
"@angular/ssr": "^17.3.8",
"@babel/runtime": "7.21.0",
"@angular/animations": "^18.2.12",
"@angular/cdk": "^18.2.12",
"@angular/common": "^18.2.12",
"@angular/compiler": "^18.2.12",
"@angular/core": "^18.2.12",
"@angular/forms": "^18.2.12",
"@angular/localize": "^18.2.12",
"@angular/platform-browser": "^18.2.12",
"@angular/platform-browser-dynamic": "^18.2.12",
"@angular/platform-server": "^18.2.12",
"@angular/router": "^18.2.12",
"@angular/ssr": "^18.2.12",
"@babel/runtime": "7.26.0",
"@kolkov/ngx-gallery": "^2.0.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.11.3",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ng-dynamic-forms/core": "^16.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0",
"@ngrx/effects": "^17.1.1",
"@ngrx/router-store": "^17.1.1",
"@ngrx/store": "^17.1.1",
"@ngx-translate/core": "^14.0.0",
"@ngrx/effects": "^18.1.1",
"@ngrx/operators": "^18.0.0",
"@ngrx/router-store": "^18.1.1",
"@ngrx/store": "^18.1.1",
"@ngx-translate/core": "^16.0.3",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
"axios": "^1.6.0",
"bootstrap": "^4.6.1",
"axios": "^1.7.9",
"bootstrap": "^5.3",
"cerialize": "0.1.18",
"cli-progress": "^3.12.0",
"colors": "^1.4.0",
"compression": "^1.7.4",
"cookie-parser": "1.4.6",
"core-js": "^3.30.1",
"compression": "^1.7.5",
"cookie-parser": "1.4.7",
"core-js": "^3.40.0",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"deepmerge": "^4.3.1",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express": "^4.21.2",
"express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.1.1",
"filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5",
"http-proxy-middleware": "^2.0.7",
"http-terminator": "^3.2.0",
"isbot": "^3.6.10",
"isbot": "^5.1.22",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.2.3",
"jsonschema": "1.4.1",
"jsonschema": "1.5.0",
"jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1",
"mirador": "^3.3.0",
"mirador": "^3.4.3",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
"mirador-share-plugin": "^0.16.0",
"morgan": "^1.10.0",
"ng-mocks": "^14.10.0",
"ng2-file-upload": "5.0.0",
"ng2-file-upload": "7.0.1",
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^16.0.0",
"ngx-infinite-scroll": "^18.0.0",
"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",
"pem": "1.14.7",
"prop-types": "^15.8.1",
"react-copy-to-clipboard": "^5.1.0",
"reflect-metadata": "^0.1.13",
"orejime": "^2.3.1",
"pem": "1.14.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"sanitize-html": "^2.12.1",
"sortablejs": "1.15.0",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "~0.14.4"
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-builders/custom-webpack": "~17.0.2",
"@angular-devkit/build-angular": "^17.3.8",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/bundled-angular-compiler": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "^17.3.8",
"@angular/compiler-cli": "^17.3.11",
"@angular/language-service": "^17.3.11",
"@angular-builders/custom-webpack": "~18.0.0",
"@angular-devkit/build-angular": "^18.2.12",
"@angular-eslint/builder": "^18.4.1",
"@angular-eslint/bundled-angular-compiler": "^18.4.1",
"@angular-eslint/eslint-plugin": "^18.4.1",
"@angular-eslint/eslint-plugin-template": "^18.4.1",
"@angular-eslint/schematics": "^18.4.1",
"@angular-eslint/template-parser": "^18.4.1",
"@angular-eslint/utils": "^18.4.1",
"@angular/cli": "^18.2.12",
"@angular/compiler-cli": "^18.2.12",
"@angular/language-service": "^18.2.12",
"@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.4.0",
"@ngrx/store-devtools": "^17.1.1",
"@ngtools/webpack": "^16.2.12",
"@types/deep-freeze": "0.1.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@ngrx/store-devtools": "^18.1.1",
"@ngtools/webpack": "^18.2.12",
"@types/deep-freeze": "0.1.5",
"@types/ejs": "^3.1.2",
"@types/express": "^4.17.17",
"@types/grecaptcha": "^3.0.9",
"@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6",
"@types/lodash": "^4.14.194",
"@types/lodash": "^4.17.15",
"@types/node": "^14.14.9",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/rule-tester": "^7.2.0",
"@typescript-eslint/utils": "^7.2.0",
"axe-core": "^4.7.2",
"browser-sync": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/rule-tester": "^7.18.0",
"@typescript-eslint/utils": "^7.18.0",
"axe-core": "^4.10.2",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
"cypress": "12.17.4",
"cypress-axe": "^1.4.0",
"cypress": "^13.17.0",
"cypress-axe": "^1.6.0",
"deep-freeze": "0.0.1",
"eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.4.1",
"eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html",
"eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-dspace-angular-html": "file:./lint/dist/src/rules/html",
"eslint-plugin-dspace-angular-ts": "file:./lint/dist/src/rules/ts",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-jsonc": "^2.19.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.7",
"eslint-plugin-unused-imports": "^3.2.0",
"express-static-gzip": "^2.2.0",
"jasmine": "^3.8.0",
"jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
"karma": "^6.4.2",
"karma": "^6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5",
"ng-mocks": "^14.13.2",
"ngx-mask": "14.2.4",
"nodemon": "^2.0.22",
"postcss": "^8.4",
"postcss-apply": "0.12.0",
"postcss": "^8.5",
"postcss-import": "^14.0.0",
"postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2",
"postcss-responsive-type": "1.0.0",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"rimraf": "^3.0.2",
"rxjs-spy": "^8.0.2",
"sass": "~1.62.0",
"sass": "~1.84.0",
"sass-loader": "^12.6.0",
"sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2",
"typescript": "~5.3.3",
"webpack": "5.90.3",
"webpack-bundle-analyzer": "^4.8.0",
"typescript": "~5.4.5",
"webpack": "5.97.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}

View File

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

View File

@@ -38,7 +38,7 @@ parseCliInput();
function parseCliInput() {
program
.option('-d, --output-dir <output-dir>', 'output dir when running script on all language files', projectRoot(LANGUAGE_FILES_LOCATION))
.option('-s, --source-dir <source-dir>', 'source dir of transalations to be merged')
.option('-s, --source-dir <source-dir>', 'source dir of translations to be merged')
.usage('(-s <source-dir> [-d <output-dir>])')
.parse(process.argv);

View File

@@ -20,14 +20,14 @@ import 'reflect-metadata';
/* eslint-disable import/no-namespace */
import * as morgan from 'morgan';
import * as express from 'express';
import express from 'express';
import * as ejs from 'ejs';
import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip';
import expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */
import axios from 'axios';
import LRU from 'lru-cache';
import isbot from 'isbot';
import { isbot } from 'isbot';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';
@@ -81,6 +81,9 @@ let anonymousCache: LRU<string, any>;
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
// The REST server base URL
const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl;
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
@@ -99,7 +102,7 @@ export function app() {
* If production mode is enabled in the environment file:
* - Enable Angular's production mode
* - Initialize caching of SSR rendered pages (if enabled in config.yml)
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
* - Enable compression for SSR responses. See [compression](https://github.com/expressjs/compression)
*/
if (environment.production) {
enableProdMode();
@@ -156,7 +159,7 @@ export function app() {
* Proxy the sitemaps
*/
router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`,
target: `${REST_BASE_URL}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true,
}));
@@ -165,7 +168,7 @@ export function app() {
* Proxy the linksets
*/
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
target: `${REST_BASE_URL}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true,
}));
@@ -218,7 +221,7 @@ export function app() {
* The callback function to serve server side angular
*/
function ngApp(req, res, next) {
if (environment.ssr.enabled) {
if (environment.ssr.enabled && req.method === 'GET' && (req.path === '/' || environment.ssr.paths.some(pathPrefix => req.path.startsWith(pathPrefix)))) {
// Render the page to user via SSR (server side rendering)
serverSideRender(req, res, next);
} else {
@@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) {
})
.then((html) => {
if (hasValue(html)) {
// Replace REST URL with UI URL
if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) {
html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl);
}
// save server side rendered page to cache (if any are enabled)
saveToCache(req, html);
if (sendToUser) {
@@ -428,7 +436,7 @@ function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, r
if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); }
// Update cached copy by rerendering server-side
// NOTE: In this scenario the currently cached copy will be returned to the current user.
// This re-render is peformed behind the scenes to update cached copy for next user.
// This re-render is performed behind the scenes to update cached copy for next user.
serverSideRender(req, res, next, false);
}
} else {
@@ -623,7 +631,7 @@ function start() {
* The callback function to serve health check requests
*/
function healthCheck(req, res) {
const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`;
const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`;
axios.get(baseUrl)
.then((response) => {
res.status(response.status).send(response.data);

View File

@@ -1,19 +1,13 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
<ngb-panel [id]="'browse'">
<ng-template ngbPanelHeader>
<div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')"
<ng-template ngbPanelTitle>
<div class="w-100 d-flex gap-3 justify-content-between collapse-toggle" (click)="acc.toggle('browse')"
data-test="browse">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
[attr.aria-expanded]="acc.isExpanded('browse')"
aria-controls="bulk-access-browse-panel-content">
{{ 'admin.access-control.bulk-access-browse.header' | translate }}
</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>
</ng-template>
<ng-template ngbPanelContent>
@@ -22,7 +16,7 @@
<li [ngbNavItem]="'search'" role="presentation">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent>
<div class="mx-n3">
<div class="bulk-access-search">
<ds-search [configuration]="'administrativeBulkAccess'"
[selectable]="true"
[selectionConfig]="{ repeatable: true, listId: listId }"
@@ -42,9 +36,11 @@
[showPaginator]="false"
(prev)="pagePrev()"
(next)="pageNext()">
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4">
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last '
@if ((objectsSelected$|async)?.hasSucceeded) {
<ul class="list-unstyled ms-4">
@for (object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; track object; let i = $index; let last = $last) {
<li
class="mt-4 mb-4 d-flex"
[attr.data-test]="'list-object' | dsBrowserOnly">
<ds-selectable-list-item-control [index]="i"
@@ -56,7 +52,9 @@
[showThumbnails]="false"
[viewMode]="'list'"></ds-listable-object-component-loader>
</li>
}
</ul>
}
</ds-pagination>
</ng-template>
</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 {
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import { AsyncPipe } from '@angular/common';
import {
Component,
Input,
@@ -59,11 +55,9 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
AsyncPipe,
NgbAccordionModule,
TranslateModule,
NgIf,
NgbNavModule,
ThemedSearchComponent,
BrowserOnlyPipe,
NgForOf,
NgxPaginationModule,
SelectableListItemControlComponent,
ListableObjectComponentLoaderComponent,

View File

@@ -7,10 +7,10 @@
<hr>
<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 }}
</button>
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
<button class="btn btn-primary" [dsBtnDisabled]="!canExport()" (click)="submit()">
{{ 'access-control-execute' | translate }}
</button>
</div>

View File

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

View File

@@ -1,17 +1,11 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'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">
<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">
{{ 'admin.access-control.bulk-access-settings.header' | translate }}
</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>
</ng-template>
<ng-template ngbPanelContent>

View File

@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import {
Component,
ViewChild,
@@ -16,7 +16,6 @@ import { AccessControlFormContainerComponent } from '../../../shared/access-cont
imports: [
NgbAccordionModule,
TranslateModule,
NgIf,
AccessControlFormContainerComponent,
],
standalone: true,

View File

@@ -5,10 +5,10 @@
<h1 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h1>
<div>
<button class="mr-auto btn btn-success addEPerson-button"
<button class="me-auto btn btn-success addEPerson-button"
[routerLink]="'create'">
<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>
</div>
</div>
@@ -18,13 +18,13 @@
</h2>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<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="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
</select>
</div>
<div class="flex-grow-1 mr-3 ml-3">
<div class="form-group input-group">
<div class="flex-grow-1 me-3 ms-3">
<div class="mb-3 input-group">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
@@ -41,14 +41,15 @@
</div>
</form>
<ds-loading *ngIf="searching$ | async"></ds-loading>
@if (searching$ | async) {
<ds-loading></ds-loading>
}
@if ((pageInfoState$ | async)?.totalElements > 0 && (searching$ | async) !== true) {
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (searching$ | async) !== true"
[paginationOptions]="config"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epeople" class="table table-striped table-hover table-bordered">
<thead>
@@ -60,8 +61,9 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
@for (epersonDto of (ePeopleDto$ | async)?.page; track epersonDto) {
<tr
[ngClass]="{'table-primary' : (activeEPerson$ | async) === epersonDto.eperson}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>
@@ -72,23 +74,28 @@
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngIf="epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
@if (epersonDto.ableToDelete) {
<button (click)="deleteEPerson(epersonDto.eperson)"
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
@if ((pageInfoState$ | async)?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</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 { PaginationService } from '../../core/pagination/pagination.service';
import { PageInfo } from '../../core/shared/page-info.model';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock';
@@ -151,7 +152,7 @@ describe('EPeopleRegistryComponent', () => {
paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]),
TranslateModule.forRoot(), EPeopleRegistryComponent],
TranslateModule.forRoot(), EPeopleRegistryComponent, BtnDisabledDirective],
providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },

View File

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

View File

@@ -2,15 +2,13 @@
<div class="group-form row">
<div class="col-12">
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader>
<h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
</ng-template>
<ng-template #editHeader>
@if (activeEPerson$ | async) {
<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"
[formModel]="formModel"
@@ -24,39 +22,51 @@
<i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}
</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="(canReset$ | async) !== true" type="button" (click)="resetPassword()">
@if (displayResetPassword) {
<div between class="btn-group">
<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>
<div *ngIf="canImpersonate$ | async" between class="btn-group">
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" (click)="impersonate()">
}
@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>
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
}
@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>
<button *ngIf="canDelete$ | async" after class="btn btn-danger delete-button" type="button" (click)="delete()">
}
@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>
}
</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) {
<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>
}
@if ((groups$ | async)?.payload?.totalElements > 0) {
<ds-pagination
*ngIf="(groups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[collectionSize]="(groups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
@@ -67,7 +77,8 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (groups$ | async)?.payload?.page">
@for (group of (groups$ | async)?.payload?.page; track group) {
<tr>
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)"
@@ -75,22 +86,27 @@
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
<td class="align-middle">
{{ dsoNameService.getName((group.object | async)?.payload) }}
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(groups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
}
@if ((groups$ | async)?.payload?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2" role="alert">
<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>

View File

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

View File

@@ -1,8 +1,6 @@
import {
AsyncPipe,
NgClass,
NgFor,
NgIf,
} from '@angular/common';
import {
ChangeDetectorRef,
@@ -65,6 +63,7 @@ import {
import { PageInfo } from '../../../core/shared/page-info.model';
import { Registration } from '../../../core/shared/registration.model';
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue } from '../../../shared/empty.util';
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -83,8 +82,6 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator';
templateUrl: './eperson-form.component.html',
imports: [
FormComponent,
NgIf,
NgFor,
AsyncPipe,
TranslateModule,
NgClass,
@@ -92,6 +89,7 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator';
PaginationComponent,
RouterLink,
HasNoValuePipe,
BtnDisabledDirective,
],
standalone: true,
})
@@ -189,6 +187,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/
canImpersonate$: Observable<boolean>;
/**
* The current {@link EPerson}
*/
activeEPerson$: Observable<EPerson>;
/**
* List of subscriptions
*/
@@ -254,7 +257,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
protected route: ActivatedRoute,
protected router: Router,
) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
}
ngOnInit() {
this.activeEPerson$ = this.epersonService.getActiveEPerson();
this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
if (hasValue(eperson)) {
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
@@ -262,9 +269,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.submitLabel = 'form.submit';
}
}));
}
ngOnInit() {
this.initialisePage();
}
@@ -272,20 +276,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page
*/
initialisePage() {
if (this.route.snapshot.params.id) {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
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,
label: this.translateService.instant(`${this.messagePrefix}.firstName`),
name: 'firstName',
validators: {
required: null,
@@ -294,7 +292,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
this.lastName = new DynamicInputModel({
id: 'lastName',
label: lastName,
label: this.translateService.instant(`${this.messagePrefix}.lastName`),
name: 'lastName',
validators: {
required: null,
@@ -303,7 +301,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
this.email = new DynamicInputModel({
id: 'email',
label: email,
label: this.translateService.instant(`${this.messagePrefix}.email`),
name: 'email',
validators: {
required: null,
@@ -314,19 +312,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail',
},
hint: emailHint,
hint: this.translateService.instant(`${this.messagePrefix}.emailHint`),
});
this.canLogIn = new DynamicCheckboxModel(
{
id: 'canLogIn',
label: 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: requireCertificate,
label: this.translateService.instant(`${this.messagePrefix}.requireCertificate`),
name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false),
});
@@ -338,12 +336,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.requireCertificate,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
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') : '',
@@ -361,9 +359,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
}));
const activeEPerson$ = this.epersonService.getActiveEPerson();
this.groups$ = activeEPerson$.pipe(
this.groups$ = this.activeEPerson$.pipe(
switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1,
@@ -382,7 +378,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
map(groupsRD => groupsRD.payload.pageInfo),
);
this.canImpersonate$ = activeEPerson$.pipe(
this.canImpersonate$ = this.activeEPerson$.pipe(
switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
@@ -391,11 +387,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
}),
);
this.canDelete$ = activeEPerson$.pipe(
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
*/
onSubmit() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe(
this.activeEPerson$.pipe(take(1)).subscribe(
(ePerson: EPerson) => {
const values = {
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.
*/
delete(): void {
this.epersonService.getActiveEPerson().pipe(
this.activeEPerson$.pipe(
take(1),
switchMap((eperson: EPerson) => {
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
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => {
this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, options);
}));
}

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,12 @@
<h3>{{messagePrefix + '.headMembers' | translate}}</h3>
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0"
@if ((ePeopleMembersOfGroup | async)?.totalElements > 0) {
<ds-pagination
[paginationOptions]="config"
[collectionSize]="(ePeopleMembersOfGroup | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
@@ -20,7 +20,8 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDTO of (ePeopleMembersOfGroup | async)?.page">
@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)">
@@ -33,33 +34,39 @@
</td>
<td class="align-middle">
<div class="btn-group edit-field">
@if (epersonDTO.ableToDelete) {
<button (click)="deleteMemberFromGroup(epersonDTO.eperson)"
*ngIf="epersonDTO.ableToDelete"
[disabled]="actionConfig.remove.disabled"
[dsBtnDisabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!epersonDTO.ableToDelete"
}
@if (!epersonDTO.ableToDelete) {
<button
(click)="addMemberToGroup(epersonDTO.eperson)"
[disabled]="actionConfig.add.disabled"
[dsBtnDisabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div *ngIf="(ePeopleMembersOfGroup | async) === undefined || (ePeopleMembersOfGroup | async)?.totalElements === 0" class="alert alert-info w-100 mb-2"
@if ((ePeopleMembersOfGroup | async) === undefined || (ePeopleMembersOfGroup | async)?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-members-yet' | translate}}
</div>
}
<h3 id="search" class="border-bottom pb-2">
<span
@@ -75,8 +82,8 @@
</h3>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3">
<div class="form-group input-group mr-3">
<div class="flex-grow-1 me-3">
<div class="form-group input-group me-3">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input">
<span class="input-group-append">
@@ -91,12 +98,12 @@
</div>
</form>
<ds-pagination *ngIf="(ePeopleSearch | async)?.totalElements > 0"
@if ((ePeopleSearch | async)?.totalElements > 0) {
<ds-pagination
[paginationOptions]="configSearch"
[collectionSize]="(ePeopleSearch | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
@@ -108,7 +115,8 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeopleSearch | async)?.page">
@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)">
@@ -122,7 +130,7 @@
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="addMemberToGroup(eperson)"
[disabled]="actionConfig.add.disabled"
[dsBtnDisabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
@@ -130,16 +138,19 @@
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div *ngIf="(ePeopleSearch | async)?.totalElements === 0 && searchDone"
@if ((ePeopleSearch | async)?.totalElements === 0 && searchDone) {
<div
class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-items' | translate}}
</div>
}
</ng-container>

View File

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

View File

@@ -3,12 +3,12 @@
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0"
@if ((subGroups$ | async)?.payload?.totalElements > 0) {
<ds-pagination
[paginationOptions]="config"
[collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
@@ -20,7 +20,8 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
@for (group of (subGroups$ | async)?.payload?.page; track group) {
<tr>
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
@@ -39,15 +40,19 @@
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</ds-pagination>
}
<div *ngIf="(subGroups$ | async)?.payload?.totalElements === 0" class="alert alert-info w-100 mb-2"
@if ((subGroups$ | async)?.payload?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
}
<h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{
@@ -62,8 +67,8 @@
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div class="flex-grow-1 mr-3">
<div class="form-group input-group mr-3">
<div class="flex-grow-1 me-3">
<div class="mb-3 input-group me-3">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input">
<span class="input-group-append">
@@ -75,18 +80,18 @@
</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}}
</button>
</div>
</form>
<ds-pagination *ngIf="(searchResults$ | async)?.payload?.totalElements > 0"
@if ((searchResults$ | async)?.payload?.totalElements > 0) {
<ds-pagination
[paginationOptions]="configSearch"
[collectionSize]="(searchResults$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
<thead>
@@ -98,7 +103,8 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
@for (group of (searchResults$ | async)?.payload?.page; track group) {
<tr>
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
@@ -117,14 +123,18 @@
</div>
</td>
</tr>
}
</tbody>
</table>
</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) {
<div class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-items' | translate}}
</div>
}
</ng-container>

View File

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

View File

@@ -4,18 +4,18 @@
<div class="d-flex justify-content-between border-bottom mb-3">
<h1 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h1>
<div>
<button class="mr-auto btn btn-success"
<button class="me-auto btn btn-success"
[routerLink]="'create'">
<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>
</div>
</div>
<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">
<div class="flex-grow-1 mr-3">
<div class="form-group input-group">
<div class="flex-grow-1 me-3">
<div class="mb-3 input-group">
<input type="text" name="query" id="query" formControlName="query"
class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
@@ -33,14 +33,15 @@
</div>
</form>
<ds-loading *ngIf="loading$ | async"></ds-loading>
@if (loading$ | async) {
<ds-loading></ds-loading>
}
@if ((pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true) {
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true"
[paginationOptions]="config"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
@@ -53,46 +54,57 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
@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">
<ng-container [ngSwitch]="groupDto.ableToEdit">
<button *ngSwitchCase="true"
@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>
<button *ngSwitchCase="false"
[disabled]="true"
}
@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>
</ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
}
}
@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 *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
@if ((pageInfoState$ | async)?.totalElements === 0) {
<div class="alert alert-info w-100 mb-2" role="alert">
{{messagePrefix + 'no-items' | translate}}
</div>
}
</div>
</div>

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