mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'main' into portuguese_pt-PT-message-keys
This commit is contained in:
@@ -1,17 +0,0 @@
|
|||||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
|
||||||
# For additional information regarding the format and rule options, please see:
|
|
||||||
# https://github.com/browserslist/browserslist#queries
|
|
||||||
|
|
||||||
# For the full list of supported browsers by the Angular framework, please see:
|
|
||||||
# https://angular.io/guide/browser-support
|
|
||||||
|
|
||||||
# You can see what browsers were selected by your queries by running:
|
|
||||||
# npx browserslist
|
|
||||||
|
|
||||||
last 1 Chrome version
|
|
||||||
last 1 Firefox version
|
|
||||||
last 2 Edge major versions
|
|
||||||
last 2 Safari major versions
|
|
||||||
last 2 iOS major versions
|
|
||||||
Firefox ESR
|
|
||||||
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.
|
|
@@ -15,3 +15,6 @@ trim_trailing_whitespace = false
|
|||||||
|
|
||||||
[*.ts]
|
[*.ts]
|
||||||
quote_type = single
|
quote_type = single
|
||||||
|
|
||||||
|
[*.json5]
|
||||||
|
ij_json_keep_blank_lines_in_code = 3
|
||||||
|
@@ -7,7 +7,8 @@
|
|||||||
"eslint-plugin-jsdoc",
|
"eslint-plugin-jsdoc",
|
||||||
"eslint-plugin-deprecation",
|
"eslint-plugin-deprecation",
|
||||||
"unused-imports",
|
"unused-imports",
|
||||||
"eslint-plugin-lodash"
|
"eslint-plugin-lodash",
|
||||||
|
"eslint-plugin-jsonc"
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
@@ -224,6 +225,42 @@
|
|||||||
"@angular-eslint/template/no-negated-async": "off",
|
"@angular-eslint/template/no-negated-async": "off",
|
||||||
"@angular-eslint/template/eqeqeq": "off"
|
"@angular-eslint/template/eqeqeq": "off"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.json5"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"plugin:jsonc/recommended-with-jsonc"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-irregular-whitespace": "error",
|
||||||
|
"no-trailing-spaces": "error",
|
||||||
|
"jsonc/comma-dangle": [
|
||||||
|
"error",
|
||||||
|
"always-multiline"
|
||||||
|
],
|
||||||
|
"jsonc/indent": [
|
||||||
|
"error",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"jsonc/key-spacing": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"beforeColon": false,
|
||||||
|
"afterColon": true,
|
||||||
|
"mode": "strict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsonc/no-dupe-keys": "off",
|
||||||
|
"jsonc/quotes": [
|
||||||
|
"error",
|
||||||
|
"double",
|
||||||
|
{
|
||||||
|
"avoidEscape": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -15,15 +15,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
# The ci step will test the dspace-angular code against DSpace REST.
|
# The ci step will test the dspace-angular code against DSpace REST.
|
||||||
# Direct that step to utilize a DSpace REST service that has been started in docker.
|
# Direct that step to utilize a DSpace REST service that has been started in docker.
|
||||||
|
# NOTE: These settings should be kept in sync with those in [src]/docker/docker-compose-ci.yml
|
||||||
DSPACE_REST_HOST: 127.0.0.1
|
DSPACE_REST_HOST: 127.0.0.1
|
||||||
DSPACE_REST_PORT: 8080
|
DSPACE_REST_PORT: 8080
|
||||||
DSPACE_REST_NAMESPACE: '/server'
|
DSPACE_REST_NAMESPACE: '/server'
|
||||||
DSPACE_REST_SSL: false
|
DSPACE_REST_SSL: false
|
||||||
# Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+
|
# Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+
|
||||||
DSPACE_UI_HOST: 127.0.0.1
|
DSPACE_UI_HOST: 127.0.0.1
|
||||||
|
DSPACE_UI_PORT: 4000
|
||||||
|
# Ensure all SSR caching is disabled in test environment
|
||||||
|
DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0
|
||||||
|
DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0
|
||||||
|
# Tell Cypress to run e2e tests using the same UI URL
|
||||||
|
CYPRESS_BASE_URL: http://127.0.0.1:4000
|
||||||
# When Chrome version is specified, we pin to a specific version of Chrome
|
# When Chrome version is specified, we pin to a specific version of Chrome
|
||||||
# Comment this out to use the latest release
|
# Comment this out to use the latest release
|
||||||
#CHROME_VERSION: "90.0.4430.212-1"
|
#CHROME_VERSION: "90.0.4430.212-1"
|
||||||
|
# Bump Node heap size (OOM in CI after upgrading to Angular 15)
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||||
strategy:
|
strategy:
|
||||||
# Create a matrix of Node versions to test against (in parallel)
|
# Create a matrix of Node versions to test against (in parallel)
|
||||||
matrix:
|
matrix:
|
||||||
@@ -61,7 +70,7 @@ jobs:
|
|||||||
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
|
# https://github.com/actions/cache/blob/main/examples.md#node---yarn
|
||||||
- name: Get Yarn cache directory
|
- name: Get Yarn cache directory
|
||||||
id: yarn-cache-dir-path
|
id: yarn-cache-dir-path
|
||||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||||
- name: Cache Yarn dependencies
|
- name: Cache Yarn dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
@@ -86,12 +95,16 @@ jobs:
|
|||||||
- name: Run specs (unit tests)
|
- name: Run specs (unit tests)
|
||||||
run: yarn run test:headless
|
run: yarn 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)
|
||||||
# NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286
|
# NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286
|
||||||
# Upload coverage reports to Codecov (for one version of Node only)
|
- name: Upload code coverage report to Artifact
|
||||||
# https://github.com/codecov/codecov-action
|
uses: actions/upload-artifact@v3
|
||||||
- name: Upload coverage to Codecov.io
|
if: matrix.node-version == '18.x'
|
||||||
uses: codecov/codecov-action@v3
|
with:
|
||||||
if: matrix.node-version == '16.x'
|
name: dspace-angular coverage report
|
||||||
|
path: 'coverage/dspace-angular/lcov.info'
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# Using docker-compose start backend using CI configuration
|
# Using docker-compose start backend using CI configuration
|
||||||
# and load assetstore from a cached copy
|
# and load assetstore from a cached copy
|
||||||
@@ -105,11 +118,10 @@ jobs:
|
|||||||
# https://github.com/cypress-io/github-action
|
# https://github.com/cypress-io/github-action
|
||||||
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
# (NOTE: to run these e2e tests locally, just use 'ng e2e')
|
||||||
- name: Run e2e tests (integration tests)
|
- name: Run e2e tests (integration tests)
|
||||||
uses: cypress-io/github-action@v4
|
uses: cypress-io/github-action@v5
|
||||||
with:
|
with:
|
||||||
# Run tests in Chrome, headless mode
|
# Run tests in Chrome, headless mode (default)
|
||||||
browser: chrome
|
browser: chrome
|
||||||
headless: true
|
|
||||||
# Start app before running tests (will be stopped automatically after tests finish)
|
# Start app before running tests (will be stopped automatically after tests finish)
|
||||||
start: yarn run serve:ssr
|
start: yarn run serve:ssr
|
||||||
# Wait for backend & frontend to be available
|
# Wait for backend & frontend to be available
|
||||||
@@ -169,3 +181,32 @@ jobs:
|
|||||||
|
|
||||||
- name: Shutdown Docker containers
|
- name: Shutdown Docker containers
|
||||||
run: docker-compose -f ./docker/docker-compose-ci.yml down
|
run: docker-compose -f ./docker/docker-compose-ci.yml down
|
||||||
|
|
||||||
|
# Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
|
||||||
|
# job above. This is necessary because Codecov uploads seem to randomly fail at times.
|
||||||
|
# See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954
|
||||||
|
codecov:
|
||||||
|
# Must run after 'tests' job above
|
||||||
|
needs: tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Download artifacts from previous 'tests' job
|
||||||
|
- name: Download coverage artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
|
||||||
|
# Now attempt upload to Codecov using its action.
|
||||||
|
# NOTE: We use a retry action to retry the Codecov upload if it fails the first time.
|
||||||
|
#
|
||||||
|
# Retry action: https://github.com/marketplace/actions/retry-action
|
||||||
|
# Codecov action: https://github.com/codecov/codecov-action
|
||||||
|
- name: Upload coverage to Codecov.io
|
||||||
|
uses: Wandalen/wretry.action@v1.0.36
|
||||||
|
with:
|
||||||
|
action: codecov/codecov-action@v3
|
||||||
|
# Try upload 5 times max
|
||||||
|
attempt_limit: 5
|
||||||
|
# Run again in 30 seconds
|
||||||
|
attempt_delay: 30000
|
||||||
|
30
.github/workflows/docker.yml
vendored
30
.github/workflows/docker.yml
vendored
@@ -88,3 +88,33 @@ jobs:
|
|||||||
# Use tags / labels provided by 'docker/metadata-action' above
|
# Use tags / labels provided by 'docker/metadata-action' above
|
||||||
tags: ${{ steps.meta_build.outputs.tags }}
|
tags: ${{ steps.meta_build.outputs.tags }}
|
||||||
labels: ${{ steps.meta_build.outputs.labels }}
|
labels: ${{ steps.meta_build.outputs.labels }}
|
||||||
|
|
||||||
|
#####################################################
|
||||||
|
# Build/Push the 'dspace/dspace-angular' image ('-dist' tag)
|
||||||
|
#####################################################
|
||||||
|
# https://github.com/docker/metadata-action
|
||||||
|
# Get Metadata for docker_build_dist step below
|
||||||
|
- name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image
|
||||||
|
id: meta_build_dist
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: dspace/dspace-angular
|
||||||
|
tags: ${{ env.IMAGE_TAGS }}
|
||||||
|
# As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same
|
||||||
|
# tagging logic as the primary 'dspace/dspace-angular' image above.
|
||||||
|
flavor: ${{ env.TAGS_FLAVOR }}
|
||||||
|
suffix=-dist
|
||||||
|
|
||||||
|
- name: Build and push 'dspace-angular-dist' image
|
||||||
|
id: docker_build_dist
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.dist
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
# For pull requests, we run the Docker build (to ensure no PR changes break the build),
|
||||||
|
# but we ONLY do an image push to DockerHub if it's NOT a PR
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
# Use tags / labels provided by 'docker/metadata-action' above
|
||||||
|
tags: ${{ steps.meta_build_dist.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta_build_dist.outputs.labels }}
|
||||||
|
2
.github/workflows/issue_opened.yml
vendored
2
.github/workflows/issue_opened.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
# Only add to project board if issue is flagged as "needs triage" or has no labels
|
# Only add to project board if issue is flagged as "needs triage" or has no labels
|
||||||
# NOTE: By default we flag new issues as "needs triage" in our issue template
|
# NOTE: By default we flag new issues as "needs triage" in our issue template
|
||||||
if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '')
|
if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '')
|
||||||
uses: actions/add-to-project@v0.3.0
|
uses: actions/add-to-project@v0.5.0
|
||||||
# Note, the authentication token below is an ORG level Secret.
|
# Note, the authentication token below is an ORG level Secret.
|
||||||
# It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions
|
# It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions
|
||||||
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token
|
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token
|
||||||
|
2
.github/workflows/label_merge_conflicts.yml
vendored
2
.github/workflows/label_merge_conflicts.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# See: https://github.com/prince-chrismc/label-merge-conflicts-action
|
# See: https://github.com/prince-chrismc/label-merge-conflicts-action
|
||||||
- name: Auto-label PRs with merge conflicts
|
- name: Auto-label PRs with merge conflicts
|
||||||
uses: prince-chrismc/label-merge-conflicts-action@v2
|
uses: prince-chrismc/label-merge-conflicts-action@v3
|
||||||
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
|
# Add "merge conflict" label if a merge conflict is detected. Remove it when resolved.
|
||||||
# Note, the authentication token is created automatically
|
# Note, the authentication token is created automatically
|
||||||
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
|
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token
|
||||||
|
15
Dockerfile
15
Dockerfile
@@ -2,20 +2,27 @@
|
|||||||
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
|
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
|
||||||
|
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
WORKDIR /app
|
|
||||||
ADD . /app/
|
|
||||||
EXPOSE 4000
|
|
||||||
|
|
||||||
# Ensure Python and other build tools are available
|
# Ensure Python and other build tools are available
|
||||||
# These are needed to install some node modules, especially on linux/arm64
|
# These are needed to install some node modules, especially on linux/arm64
|
||||||
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
|
RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ADD . /app/
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
# We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
|
# 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
|
# See, for example https://github.com/yarnpkg/yarn/issues/5540
|
||||||
RUN yarn install --network-timeout 300000
|
RUN yarn install --network-timeout 300000
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/
|
||||||
|
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
|
|
||||||
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
|
# On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc).
|
||||||
# Listen / accept connections from all IP addresses.
|
# Listen / accept connections from all IP addresses.
|
||||||
# NOTE: At this time it is only possible to run Docker container in Production mode
|
# NOTE: At this time it is only possible to run Docker container in Production mode
|
||||||
# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485
|
# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485
|
||||||
|
ENV NODE_ENV development
|
||||||
CMD yarn serve --host 0.0.0.0
|
CMD yarn serve --host 0.0.0.0
|
||||||
|
31
Dockerfile.dist
Normal file
31
Dockerfile.dist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# This image will be published as dspace/dspace-angular:$DSPACE_VERSION-dist
|
||||||
|
# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details
|
||||||
|
|
||||||
|
# Test build:
|
||||||
|
# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
|
||||||
|
|
||||||
|
FROM 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
|
||||||
|
|
||||||
|
ADD . /app/
|
||||||
|
RUN yarn build:prod
|
||||||
|
|
||||||
|
FROM node:18-alpine
|
||||||
|
RUN npm install --global pm2
|
||||||
|
|
||||||
|
COPY --chown=node:node --from=build /app/dist /app/dist
|
||||||
|
COPY --chown=node:node config /app/config
|
||||||
|
COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
USER node
|
||||||
|
ENV NODE_ENV production
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD pm2-runtime start dspace-ui.json --json
|
16
angular.json
16
angular.json
@@ -266,16 +266,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": [
|
"lintFilePatterns": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.html"
|
"src/**/*.html",
|
||||||
|
"src/**/*.json5"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultProject": "dspace-angular",
|
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": false,
|
"analytics": false,
|
||||||
"defaultCollection": "@angular-eslint/schematics"
|
"schematicCollections": [
|
||||||
|
"@angular-eslint/schematics"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schematics": {
|
||||||
|
"@angular-eslint/schematics:application": {
|
||||||
|
"setParserOptionsProject": true
|
||||||
|
},
|
||||||
|
"@angular-eslint/schematics:library": {
|
||||||
|
"setParserOptionsProject": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -32,12 +32,60 @@ cache:
|
|||||||
# NOTE: how long should objects be cached for by default
|
# NOTE: how long should objects be cached for by default
|
||||||
msToLive:
|
msToLive:
|
||||||
default: 900000 # 15 minutes
|
default: 900000 # 15 minutes
|
||||||
control: max-age=60 # revalidate browser
|
# Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files)
|
||||||
|
# Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these
|
||||||
|
# files for one week, after which they will be "stale" and need to be redownloaded.
|
||||||
|
# NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because
|
||||||
|
# all compiled *.js files include a unique hash in their name which updates when content is modified.
|
||||||
|
control: max-age=604800 # revalidate browser
|
||||||
autoSync:
|
autoSync:
|
||||||
defaultTime: 0
|
defaultTime: 0
|
||||||
maxBufferSize: 100
|
maxBufferSize: 100
|
||||||
timePerMethod:
|
timePerMethod:
|
||||||
PATCH: 3 # time in seconds
|
PATCH: 3 # time in seconds
|
||||||
|
# In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages.
|
||||||
|
# Pages are automatically added/dropped from these caches based on how recently they have been used.
|
||||||
|
# Restarting the app clears all page caches.
|
||||||
|
# NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB).
|
||||||
|
# Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive).
|
||||||
|
serverSide:
|
||||||
|
# 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.)
|
||||||
|
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.
|
||||||
|
# As all pages are cached in server memory, increasing this value will increase memory needs.
|
||||||
|
# Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory.
|
||||||
|
max: 1000
|
||||||
|
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
|
||||||
|
# copy is automatically refreshed on the next request.
|
||||||
|
# NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site.
|
||||||
|
# For example, setting this to one week may mean that search engine bots may not find all new content for one week.
|
||||||
|
timeToLive: 86400000 # 1 day
|
||||||
|
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
|
||||||
|
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
|
||||||
|
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
|
||||||
|
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
|
||||||
|
allowStale: true
|
||||||
|
# When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache.
|
||||||
|
# This allows anonymous users to interact more quickly with the site, but also means they may see slightly
|
||||||
|
# outdated content (based on timeToLive)
|
||||||
|
anonymousCache:
|
||||||
|
# Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled.
|
||||||
|
# As all pages are cached in server memory, increasing this value will increase memory needs.
|
||||||
|
# Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory.
|
||||||
|
max: 0
|
||||||
|
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
|
||||||
|
# copy is automatically refreshed on the next request.
|
||||||
|
# NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content.
|
||||||
|
timeToLive: 10000 # 10 seconds
|
||||||
|
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
|
||||||
|
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
|
||||||
|
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
|
||||||
|
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
|
||||||
|
allowStale: true
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
auth:
|
auth:
|
||||||
@@ -121,6 +169,9 @@ languages:
|
|||||||
- code: en
|
- code: en
|
||||||
label: English
|
label: English
|
||||||
active: true
|
active: true
|
||||||
|
- code: ca
|
||||||
|
label: Català
|
||||||
|
active: true
|
||||||
- code: cs
|
- code: cs
|
||||||
label: Čeština
|
label: Čeština
|
||||||
active: true
|
active: true
|
||||||
@@ -136,6 +187,9 @@ languages:
|
|||||||
- code: gd
|
- code: gd
|
||||||
label: Gàidhlig
|
label: Gàidhlig
|
||||||
active: true
|
active: true
|
||||||
|
- code: it
|
||||||
|
label: Italiano
|
||||||
|
active: true
|
||||||
- code: lv
|
- code: lv
|
||||||
label: Latviešu
|
label: Latviešu
|
||||||
active: true
|
active: true
|
||||||
@@ -163,6 +217,9 @@ languages:
|
|||||||
- code: tr
|
- code: tr
|
||||||
label: Türkçe
|
label: Türkçe
|
||||||
active: true
|
active: true
|
||||||
|
- code: vi
|
||||||
|
label: Tiếng Việt
|
||||||
|
active: true
|
||||||
- code: kk
|
- code: kk
|
||||||
label: Қазақ
|
label: Қазақ
|
||||||
active: true
|
active: true
|
||||||
@@ -310,3 +367,16 @@ info:
|
|||||||
markdown:
|
markdown:
|
||||||
enabled: false
|
enabled: false
|
||||||
mathjax: false
|
mathjax: false
|
||||||
|
|
||||||
|
# Which vocabularies should be used for which search filters
|
||||||
|
# and whether to show the filter in the search sidebar
|
||||||
|
# Take a look at the filter-vocabulary-config.ts file for documentation on how the options are obtained
|
||||||
|
vocabularies:
|
||||||
|
- filter: 'subject'
|
||||||
|
vocabulary: 'srsc'
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
|
||||||
|
comcolSelectionSort:
|
||||||
|
sortField: 'dc.title'
|
||||||
|
sortDirection: 'ASC'
|
44
cypress.config.ts
Normal file
44
cypress.config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
videosFolder: 'cypress/videos',
|
||||||
|
screenshotsFolder: 'cypress/screenshots',
|
||||||
|
fixturesFolder: 'cypress/fixtures',
|
||||||
|
retries: {
|
||||||
|
runMode: 2,
|
||||||
|
openMode: 0,
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
// Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts)
|
||||||
|
// May be overridden in our cypress.json config file using specified environment variables.
|
||||||
|
// Default values listed here are all valid for the Demo Entities Data set available at
|
||||||
|
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
||||||
|
// (This is the data set used in our CI environment)
|
||||||
|
|
||||||
|
// Admin account used for administrative tests
|
||||||
|
DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com',
|
||||||
|
DSPACE_TEST_ADMIN_PASSWORD: 'dspace',
|
||||||
|
// Community/collection/publication used for view/edit tests
|
||||||
|
DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4',
|
||||||
|
DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200',
|
||||||
|
DSPACE_TEST_ENTITY_PUBLICATION: 'e98b0f27-5c19-49a0-960d-eb6ad5287067',
|
||||||
|
// Search term (should return results) used in search tests
|
||||||
|
DSPACE_TEST_SEARCH_TERM: 'test',
|
||||||
|
// Collection used for submission tests
|
||||||
|
DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection',
|
||||||
|
DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144',
|
||||||
|
// Account used to test basic submission process
|
||||||
|
DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com',
|
||||||
|
DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace',
|
||||||
|
},
|
||||||
|
e2e: {
|
||||||
|
// Setup our plugins for e2e tests
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
return require('./cypress/plugins/index.ts')(on, config);
|
||||||
|
},
|
||||||
|
// This is the base URL that Cypress will run all tests against
|
||||||
|
// It can be overridden via the CYPRESS_BASE_URL environment variable
|
||||||
|
// (By default we set this to a value which should work in most development environments)
|
||||||
|
baseUrl: 'http://localhost:4000',
|
||||||
|
},
|
||||||
|
});
|
25
cypress.json
25
cypress.json
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"integrationFolder": "cypress/integration",
|
|
||||||
"supportFile": "cypress/support/index.ts",
|
|
||||||
"videosFolder": "cypress/videos",
|
|
||||||
"screenshotsFolder": "cypress/screenshots",
|
|
||||||
"pluginsFile": "cypress/plugins/index.ts",
|
|
||||||
"fixturesFolder": "cypress/fixtures",
|
|
||||||
"baseUrl": "http://127.0.0.1:4000",
|
|
||||||
"retries": {
|
|
||||||
"runMode": 2,
|
|
||||||
"openMode": 0
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com",
|
|
||||||
"DSPACE_TEST_ADMIN_PASSWORD": "dspace",
|
|
||||||
"DSPACE_TEST_COMMUNITY": "0958c910-2037-42a9-81c7-dca80e3892b4",
|
|
||||||
"DSPACE_TEST_COLLECTION": "282164f5-d325-4740-8dd1-fa4d6d3e7200",
|
|
||||||
"DSPACE_TEST_ENTITY_PUBLICATION": "e98b0f27-5c19-49a0-960d-eb6ad5287067",
|
|
||||||
"DSPACE_TEST_SEARCH_TERM": "test",
|
|
||||||
"DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection",
|
|
||||||
"DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144",
|
|
||||||
"DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com",
|
|
||||||
"DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +1,10 @@
|
|||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Breadcrumbs', () => {
|
describe('Breadcrumbs', () => {
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
// Visit an Item, as those have more breadcrumbs
|
// Visit an Item, as those have more breadcrumbs
|
||||||
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
|
cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
|
||||||
|
|
||||||
// Wait for breadcrumbs to be visible
|
// Wait for breadcrumbs to be visible
|
||||||
cy.get('ds-breadcrumbs').should('be.visible');
|
cy.get('ds-breadcrumbs').should('be.visible');
|
@@ -1,13 +1,13 @@
|
|||||||
import { TEST_COLLECTION } from 'cypress/support';
|
import { TEST_COLLECTION } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Collection Page', () => {
|
describe('Collection Page', () => {
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
cy.visit('/collections/' + TEST_COLLECTION);
|
cy.visit('/collections/'.concat(TEST_COLLECTION));
|
||||||
|
|
||||||
// <ds-collection-page> tag must be loaded
|
// <ds-collection-page> tag must be loaded
|
||||||
cy.get('ds-collection-page').should('exist');
|
cy.get('ds-collection-page').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-collection-page> for accessibility issues
|
// Analyze <ds-collection-page> for accessibility issues
|
||||||
testA11y('ds-collection-page');
|
testA11y('ds-collection-page');
|
37
cypress/e2e/collection-statistics.cy.ts
Normal file
37
cypress/e2e/collection-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Collection Statistics Page', () => {
|
||||||
|
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION);
|
||||||
|
|
||||||
|
it('should load if you click on "Statistics" from a Collection page', () => {
|
||||||
|
cy.visit('/collections/'.concat(TEST_COLLECTION));
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||||
|
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits" section', () => {
|
||||||
|
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||||
|
cy.get('table[data-test="TotalVisits"]').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits per month" section', () => {
|
||||||
|
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||||
|
// Check just for existence because this table is empty in CI environment as it's historical data
|
||||||
|
cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||||
|
|
||||||
|
// <ds-collection-statistics-page> tag must be loaded
|
||||||
|
cy.get('ds-collection-statistics-page').should('be.visible');
|
||||||
|
|
||||||
|
// Verify / wait until "Total Visits" table's label is non-empty
|
||||||
|
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
|
||||||
|
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
|
||||||
|
|
||||||
|
// Analyze <ds-collection-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-collection-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
@@ -7,10 +7,10 @@ describe('Community List Page', () => {
|
|||||||
cy.visit('/community-list');
|
cy.visit('/community-list');
|
||||||
|
|
||||||
// <ds-community-list-page> tag must be loaded
|
// <ds-community-list-page> tag must be loaded
|
||||||
cy.get('ds-community-list-page').should('exist');
|
cy.get('ds-community-list-page').should('be.visible');
|
||||||
|
|
||||||
// Open first Community (to show Collections)...that way we scan sub-elements as well
|
// Open every expand button on page, so that we can scan sub-elements as well
|
||||||
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click();
|
cy.get('[data-test="expand-button"]').click({ multiple: true });
|
||||||
|
|
||||||
// Analyze <ds-community-list-page> for accessibility issues
|
// Analyze <ds-community-list-page> for accessibility issues
|
||||||
// Disable heading-order checks until it is fixed
|
// Disable heading-order checks until it is fixed
|
@@ -1,13 +1,13 @@
|
|||||||
import { TEST_COMMUNITY } from 'cypress/support';
|
import { TEST_COMMUNITY } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Community Page', () => {
|
describe('Community Page', () => {
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
cy.visit('/communities/'.concat(TEST_COMMUNITY));
|
||||||
|
|
||||||
// <ds-community-page> tag must be loaded
|
// <ds-community-page> tag must be loaded
|
||||||
cy.get('ds-community-page').should('exist');
|
cy.get('ds-community-page').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-community-page> for accessibility issues
|
// Analyze <ds-community-page> for accessibility issues
|
||||||
testA11y('ds-community-page',);
|
testA11y('ds-community-page',);
|
37
cypress/e2e/community-statistics.cy.ts
Normal file
37
cypress/e2e/community-statistics.cy.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
|
describe('Community Statistics Page', () => {
|
||||||
|
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY);
|
||||||
|
|
||||||
|
it('should load if you click on "Statistics" from a Community page', () => {
|
||||||
|
cy.visit('/communities/'.concat(TEST_COMMUNITY));
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||||
|
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits" section', () => {
|
||||||
|
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||||
|
cy.get('table[data-test="TotalVisits"]').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a "Total visits per month" section', () => {
|
||||||
|
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||||
|
// Check just for existence because this table is empty in CI environment as it's historical data
|
||||||
|
cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||||
|
|
||||||
|
// <ds-community-statistics-page> tag must be loaded
|
||||||
|
cy.get('ds-community-statistics-page').should('be.visible');
|
||||||
|
|
||||||
|
// Verify / wait until "Total Visits" table's label is non-empty
|
||||||
|
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
|
||||||
|
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
|
||||||
|
|
||||||
|
// Analyze <ds-community-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-community-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
31
cypress/e2e/homepage-statistics.cy.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||||
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
import '../support/commands';
|
||||||
|
|
||||||
|
describe('Site Statistics Page', () => {
|
||||||
|
it('should load if you click on "Statistics" from homepage', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||||
|
cy.location('pathname').should('eq', '/statistics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', () => {
|
||||||
|
// generate 2 view events on an Item's page
|
||||||
|
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
|
||||||
|
cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item');
|
||||||
|
|
||||||
|
cy.visit('/statistics');
|
||||||
|
|
||||||
|
// <ds-site-statistics-page> tag must be visable
|
||||||
|
cy.get('ds-site-statistics-page').should('be.visible');
|
||||||
|
|
||||||
|
// Verify / wait until "Total Visits" table's *last* label is non-empty
|
||||||
|
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
|
||||||
|
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT);
|
||||||
|
// Wait an extra 500ms, just so all entries in Total Visits have loaded.
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
// Analyze <ds-site-statistics-page> for accessibility issues
|
||||||
|
testA11y('ds-site-statistics-page');
|
||||||
|
});
|
||||||
|
});
|
@@ -1,10 +1,10 @@
|
|||||||
import { Options } from 'cypress-axe';
|
import { Options } from 'cypress-axe';
|
||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Item Page', () => {
|
describe('Item Page', () => {
|
||||||
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
|
const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION);
|
||||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
|
||||||
|
|
||||||
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
|
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
|
||||||
it('should redirect to the entity page when navigating to an item page', () => {
|
it('should redirect to the entity page when navigating to an item page', () => {
|
||||||
@@ -16,7 +16,7 @@ describe('Item Page', () => {
|
|||||||
cy.visit(ENTITYPAGE);
|
cy.visit(ENTITYPAGE);
|
||||||
|
|
||||||
// <ds-item-page> tag must be loaded
|
// <ds-item-page> tag must be loaded
|
||||||
cy.get('ds-item-page').should('exist');
|
cy.get('ds-item-page').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-item-page> for accessibility issues
|
// Analyze <ds-item-page> for accessibility issues
|
||||||
// Disable heading-order checks until it is fixed
|
// Disable heading-order checks until it is fixed
|
@@ -1,36 +1,41 @@
|
|||||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Item Statistics Page', () => {
|
describe('Item Statistics Page', () => {
|
||||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
|
const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION);
|
||||||
|
|
||||||
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
||||||
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
|
cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION));
|
||||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||||
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
|
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
cy.get('ds-item-statistics-page').should('exist');
|
cy.get('ds-item-statistics-page').should('be.visible');
|
||||||
cy.get('ds-item-page').should('not.exist');
|
cy.get('ds-item-page').should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain a "Total visits" section', () => {
|
it('should contain a "Total visits" section', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
|
cy.get('table[data-test="TotalVisits"]').should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain a "Total visits per month" section', () => {
|
it('should contain a "Total visits per month" section', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
|
// Check just for existence because this table is empty in CI environment as it's historical data
|
||||||
|
cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
|
|
||||||
// <ds-item-statistics-page> tag must be loaded
|
// <ds-item-statistics-page> tag must be loaded
|
||||||
cy.get('ds-item-statistics-page').should('exist');
|
cy.get('ds-item-statistics-page').should('be.visible');
|
||||||
|
|
||||||
|
// Verify / wait until "Total Visits" table's label is non-empty
|
||||||
|
// (This table loads these labels asynchronously, so we want to wait for them before analyzing page)
|
||||||
|
cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT);
|
||||||
|
|
||||||
// Analyze <ds-item-statistics-page> for accessibility issues
|
// Analyze <ds-item-statistics-page> for accessibility issues
|
||||||
testA11y('ds-item-statistics-page');
|
testA11y('ds-item-statistics-page');
|
@@ -1,4 +1,4 @@
|
|||||||
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e';
|
||||||
|
|
||||||
const page = {
|
const page = {
|
||||||
openLoginMenu() {
|
openLoginMenu() {
|
||||||
@@ -36,7 +36,7 @@ const page = {
|
|||||||
|
|
||||||
describe('Login Modal', () => {
|
describe('Login Modal', () => {
|
||||||
it('should login when clicking button & stay on same page', () => {
|
it('should login when clicking button & stay on same page', () => {
|
||||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION);
|
||||||
cy.visit(ENTITYPAGE);
|
cy.visit(ENTITYPAGE);
|
||||||
|
|
||||||
// Login menu should exist
|
// Login menu should exist
|
@@ -1,5 +1,5 @@
|
|||||||
import { Options } from 'cypress-axe';
|
import { Options } from 'cypress-axe';
|
||||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support';
|
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('My DSpace page', () => {
|
describe('My DSpace page', () => {
|
||||||
@@ -9,7 +9,7 @@ describe('My DSpace page', () => {
|
|||||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||||
|
|
||||||
cy.get('ds-my-dspace-page').should('exist');
|
cy.get('ds-my-dspace-page').should('be.visible');
|
||||||
|
|
||||||
// At least one recent submission should be displayed
|
// At least one recent submission should be displayed
|
||||||
cy.get('[data-test="list-object"]').should('be.visible');
|
cy.get('[data-test="list-object"]').should('be.visible');
|
||||||
@@ -42,12 +42,12 @@ describe('My DSpace page', () => {
|
|||||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||||
|
|
||||||
cy.get('ds-my-dspace-page').should('exist');
|
cy.get('ds-my-dspace-page').should('be.visible');
|
||||||
|
|
||||||
// Click button in sidebar to display detailed view
|
// Click button in sidebar to display detailed view
|
||||||
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
|
cy.get('ds-search-sidebar [data-test="detail-view"]').click();
|
||||||
|
|
||||||
cy.get('ds-object-detail').should('exist');
|
cy.get('ds-object-detail').should('be.visible');
|
||||||
|
|
||||||
// Analyze <ds-search-page> for accessibility issues
|
// Analyze <ds-search-page> for accessibility issues
|
||||||
testA11y('ds-my-dspace-page',
|
testA11y('ds-my-dspace-page',
|
||||||
@@ -80,7 +80,7 @@ describe('My DSpace page', () => {
|
|||||||
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
|
cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME);
|
||||||
|
|
||||||
// Click on the button matching that known Collection name
|
// Click on the button matching that known Collection name
|
||||||
cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click();
|
cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click();
|
||||||
|
|
||||||
// New URL should include /workspaceitems, as we've started a new submission
|
// New URL should include /workspaceitems, as we've started a new submission
|
||||||
cy.url().should('include', '/workspaceitems');
|
cy.url().should('include', '/workspaceitems');
|
@@ -2,7 +2,7 @@ describe('PageNotFound', () => {
|
|||||||
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
|
it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => {
|
||||||
// request an invalid page (UUIDs at root path aren't valid)
|
// request an invalid page (UUIDs at root path aren't valid)
|
||||||
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
|
cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false });
|
||||||
cy.get('ds-pagenotfound').should('exist');
|
cy.get('ds-pagenotfound').should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
|
it('should not contain element ds-pagenotfound when navigating to existing page', () => {
|
@@ -1,4 +1,4 @@
|
|||||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
|
||||||
|
|
||||||
const page = {
|
const page = {
|
||||||
fillOutQueryInNavBar(query) {
|
fillOutQueryInNavBar(query) {
|
||||||
@@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingEnter();
|
page.submitQueryByPressingEnter();
|
||||||
// New URL should include query param
|
// New URL should include query param
|
||||||
cy.url().should('include', 'query=' + query);
|
cy.url().should('include', 'query='.concat(query));
|
||||||
// Wait for search results to come back from the above GET command
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
||||||
@@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingEnter();
|
page.submitQueryByPressingEnter();
|
||||||
// New URL should include query param
|
// New URL should include query param
|
||||||
cy.url().should('include', 'query=' + query);
|
cy.url().should('include', 'query='.concat(query));
|
||||||
// Wait for search results to come back from the above GET command
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
||||||
@@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => {
|
|||||||
page.fillOutQueryInNavBar(query);
|
page.fillOutQueryInNavBar(query);
|
||||||
page.submitQueryByPressingIcon();
|
page.submitQueryByPressingIcon();
|
||||||
// New URL should include query param
|
// New URL should include query param
|
||||||
cy.url().should('include', 'query=' + query);
|
cy.url().should('include', 'query='.concat(query));
|
||||||
// Wait for search results to come back from the above GET command
|
// Wait for search results to come back from the above GET command
|
||||||
cy.wait('@search-results');
|
cy.wait('@search-results');
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
@@ -1,5 +1,5 @@
|
|||||||
import { Options } from 'cypress-axe';
|
import { Options } from 'cypress-axe';
|
||||||
import { TEST_SEARCH_TERM } from 'cypress/support';
|
import { TEST_SEARCH_TERM } from 'cypress/support/e2e';
|
||||||
import { testA11y } from 'cypress/support/utils';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
describe('Search Page', () => {
|
describe('Search Page', () => {
|
||||||
@@ -13,11 +13,11 @@ describe('Search Page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should load results and pass accessibility tests', () => {
|
it('should load results and pass accessibility tests', () => {
|
||||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
|
||||||
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
|
cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM);
|
||||||
|
|
||||||
// <ds-search-page> tag must be loaded
|
// <ds-search-page> tag must be loaded
|
||||||
cy.get('ds-search-page').should('exist');
|
cy.get('ds-search-page').should('be.visible');
|
||||||
|
|
||||||
// At least one search result should be displayed
|
// At least one search result should be displayed
|
||||||
cy.get('[data-test="list-object"]').should('be.visible');
|
cy.get('[data-test="list-object"]').should('be.visible');
|
||||||
@@ -45,13 +45,13 @@ describe('Search Page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have a working grid view that passes accessibility tests', () => {
|
it('should have a working grid view that passes accessibility tests', () => {
|
||||||
cy.visit('/search?query=' + TEST_SEARCH_TERM);
|
cy.visit('/search?query='.concat(TEST_SEARCH_TERM));
|
||||||
|
|
||||||
// Click button in sidebar to display grid view
|
// Click button in sidebar to display grid view
|
||||||
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
|
cy.get('ds-search-sidebar [data-test="grid-view"]').click();
|
||||||
|
|
||||||
// <ds-search-page> tag must be loaded
|
// <ds-search-page> tag must be loaded
|
||||||
cy.get('ds-search-page').should('exist');
|
cy.get('ds-search-page').should('be.visible');
|
||||||
|
|
||||||
// At least one grid object (card) should be displayed
|
// At least one grid object (card) should be displayed
|
||||||
cy.get('[data-test="grid-object"]').should('be.visible');
|
cy.get('[data-test="grid-object"]').should('be.visible');
|
@@ -1,13 +1,11 @@
|
|||||||
import { Options } from 'cypress-axe';
|
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e';
|
||||||
import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('New Submission page', () => {
|
describe('New Submission page', () => {
|
||||||
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
|
// NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
|
||||||
|
|
||||||
it('should create a new submission when using /submit path & pass accessibility', () => {
|
it('should create a new submission when using /submit path & pass accessibility', () => {
|
||||||
// Test that calling /submit with collection & entityType will create a new submission
|
// Test that calling /submit with collection & entityType will create a new submission
|
||||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
|
||||||
|
|
||||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||||
@@ -35,7 +33,7 @@ describe('New Submission page', () => {
|
|||||||
|
|
||||||
it('should block submission & show errors if required fields are missing', () => {
|
it('should block submission & show errors if required fields are missing', () => {
|
||||||
// Create a new submission
|
// Create a new submission
|
||||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
|
||||||
|
|
||||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||||
@@ -95,7 +93,7 @@ describe('New Submission page', () => {
|
|||||||
|
|
||||||
it('should allow for deposit if all required fields completed & file uploaded', () => {
|
it('should allow for deposit if all required fields completed & file uploaded', () => {
|
||||||
// Create a new submission
|
// Create a new submission
|
||||||
cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none');
|
cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
|
||||||
|
|
||||||
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
// This page is restricted, so we will be shown the login form. Fill it out & submit.
|
||||||
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD);
|
||||||
@@ -124,8 +122,6 @@ describe('New Submission page', () => {
|
|||||||
|
|
||||||
// Wait for upload to complete before proceeding
|
// Wait for upload to complete before proceeding
|
||||||
cy.wait('@upload');
|
cy.wait('@upload');
|
||||||
// Close the upload success notice
|
|
||||||
cy.get('[data-dismiss="alert"]').click({multiple: true});
|
|
||||||
|
|
||||||
// Wait for deposit button to not be disabled & click it.
|
// Wait for deposit button to not be disabled & click it.
|
||||||
cy.get('button#deposit').should('not.be.disabled').click();
|
cy.get('button#deposit').should('not.be.disabled').click();
|
@@ -1,32 +0,0 @@
|
|||||||
import { TEST_COLLECTION } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Collection Statistics Page', () => {
|
|
||||||
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
|
|
||||||
|
|
||||||
it('should load if you click on "Statistics" from a Collection page', () => {
|
|
||||||
cy.visit('/collections/' + TEST_COLLECTION);
|
|
||||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
|
||||||
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits" section', () => {
|
|
||||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits per month" section', () => {
|
|
||||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
|
||||||
|
|
||||||
// <ds-collection-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-collection-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-collection-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-collection-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,32 +0,0 @@
|
|||||||
import { TEST_COMMUNITY } from 'cypress/support';
|
|
||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Community Statistics Page', () => {
|
|
||||||
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
|
|
||||||
|
|
||||||
it('should load if you click on "Statistics" from a Community page', () => {
|
|
||||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
|
||||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
|
||||||
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits" section', () => {
|
|
||||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain a "Total visits per month" section', () => {
|
|
||||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
|
||||||
cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
|
||||||
|
|
||||||
// <ds-community-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-community-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-community-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-community-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,19 +0,0 @@
|
|||||||
import { testA11y } from 'cypress/support/utils';
|
|
||||||
|
|
||||||
describe('Site Statistics Page', () => {
|
|
||||||
it('should load if you click on "Statistics" from homepage', () => {
|
|
||||||
cy.visit('/');
|
|
||||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
|
||||||
cy.location('pathname').should('eq', '/statistics');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass accessibility tests', () => {
|
|
||||||
cy.visit('/statistics');
|
|
||||||
|
|
||||||
// <ds-site-statistics-page> tag must be loaded
|
|
||||||
cy.get('ds-site-statistics-page').should('exist');
|
|
||||||
|
|
||||||
// Analyze <ds-site-statistics-page> for accessibility issues
|
|
||||||
testA11y('ds-site-statistics-page');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -4,12 +4,17 @@
|
|||||||
// ***********************************************
|
// ***********************************************
|
||||||
|
|
||||||
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
||||||
import { FALLBACK_TEST_REST_BASE_URL } from '.';
|
import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
|
||||||
|
|
||||||
|
// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
|
||||||
|
// from the Angular UI's config.json. See 'login()'.
|
||||||
|
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
|
||||||
|
export const FALLBACK_TEST_REST_DOMAIN = 'localhost';
|
||||||
|
|
||||||
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
|
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
|
||||||
// ALL custom commands MUST be listed here for code completion to work
|
// ALL custom commands MUST be listed here for code completion to work
|
||||||
// tslint:disable-next-line:no-namespace
|
|
||||||
declare global {
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface Chainable<Subject = any> {
|
interface Chainable<Subject = any> {
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +32,15 @@ declare global {
|
|||||||
* @param password password to login as
|
* @param password password to login as
|
||||||
*/
|
*/
|
||||||
loginViaForm(email: string, password: string): typeof loginViaForm;
|
loginViaForm(email: string, password: string): typeof loginViaForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate view event for given object. Useful for testing statistics pages with
|
||||||
|
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
|
||||||
|
* generate multiple hits.
|
||||||
|
* @param uuid UUID of object
|
||||||
|
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
|
||||||
|
*/
|
||||||
|
generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,52 +67,57 @@ function login(email: string, password: string): void {
|
|||||||
if (!config.rest.baseUrl) {
|
if (!config.rest.baseUrl) {
|
||||||
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
|
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
|
||||||
} else {
|
} else {
|
||||||
console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: " + config.rest.baseUrl);
|
//console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl));
|
||||||
baseRestUrl = config.rest.baseUrl;
|
baseRestUrl = config.rest.baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// To login via REST, first we have to do a GET to obtain a valid CSRF token
|
// Now find domain of our REST API, again with a fallback.
|
||||||
cy.request( baseRestUrl + '/api/authn/status' )
|
let baseDomain = FALLBACK_TEST_REST_DOMAIN;
|
||||||
.then((response) => {
|
if (!config.rest.host) {
|
||||||
// We should receive a CSRF token returned in a response header
|
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
|
||||||
expect(response.headers).to.have.property('dspace-xsrf-token');
|
} else {
|
||||||
const csrfToken = response.headers['dspace-xsrf-token'];
|
baseDomain = config.rest.host;
|
||||||
|
}
|
||||||
|
|
||||||
// Now, send login POST request including that CSRF token
|
// Create a fake CSRF Token. Set it in the required server-side cookie
|
||||||
cy.request({
|
const csrfToken = 'fakeLoginCSRFToken';
|
||||||
method: 'POST',
|
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
|
||||||
url: baseRestUrl + '/api/authn/login',
|
|
||||||
headers: { 'X-XSRF-TOKEN' : csrfToken},
|
|
||||||
form: true, // indicates the body should be form urlencoded
|
|
||||||
body: { user: email, password: password }
|
|
||||||
}).then((resp) => {
|
|
||||||
// We expect a successful login
|
|
||||||
expect(resp.status).to.eq(200);
|
|
||||||
// We expect to have a valid authorization header returned (with our auth token)
|
|
||||||
expect(resp.headers).to.have.property('authorization');
|
|
||||||
|
|
||||||
// Initialize our AuthTokenInfo object from the authorization header.
|
// Now, send login POST request including that CSRF token
|
||||||
const authheader = resp.headers.authorization as string;
|
cy.request({
|
||||||
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
|
method: 'POST',
|
||||||
|
url: baseRestUrl + '/api/authn/login',
|
||||||
|
headers: { [XSRF_REQUEST_HEADER]: csrfToken},
|
||||||
|
form: true, // indicates the body should be form urlencoded
|
||||||
|
body: { user: email, password: password }
|
||||||
|
}).then((resp) => {
|
||||||
|
// We expect a successful login
|
||||||
|
expect(resp.status).to.eq(200);
|
||||||
|
// We expect to have a valid authorization header returned (with our auth token)
|
||||||
|
expect(resp.headers).to.have.property('authorization');
|
||||||
|
|
||||||
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie
|
// Initialize our AuthTokenInfo object from the authorization header.
|
||||||
// This ensures the UI will recognize we are logged in on next "visit()"
|
const authheader = resp.headers.authorization as string;
|
||||||
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
|
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader);
|
||||||
});
|
|
||||||
|
// Save our AuthTokenInfo object to our dsAuthInfo UI cookie
|
||||||
|
// This ensures the UI will recognize we are logged in on next "visit()"
|
||||||
|
cy.setCookie(TOKENITEM, JSON.stringify(authinfo));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove cookie with fake CSRF token, as it's no longer needed
|
||||||
|
cy.clearCookie(DSPACE_XSRF_COOKIE);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Add as a Cypress command (i.e. assign to 'cy.login')
|
// Add as a Cypress command (i.e. assign to 'cy.login')
|
||||||
Cypress.Commands.add('login', login);
|
Cypress.Commands.add('login', login);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login user via displayed login form
|
* Login user via displayed login form
|
||||||
* @param email email to login as
|
* @param email email to login as
|
||||||
* @param password password to login as
|
* @param password password to login as
|
||||||
*/
|
*/
|
||||||
function loginViaForm(email: string, password: string): void {
|
function loginViaForm(email: string, password: string): void {
|
||||||
// Enter email
|
// Enter email
|
||||||
cy.get('ds-log-in [data-test="email"]').type(email);
|
cy.get('ds-log-in [data-test="email"]').type(email);
|
||||||
// Enter password
|
// Enter password
|
||||||
@@ -107,4 +126,69 @@ Cypress.Commands.add('login', login);
|
|||||||
cy.get('ds-log-in [data-test="login-button"]').click();
|
cy.get('ds-log-in [data-test="login-button"]').click();
|
||||||
}
|
}
|
||||||
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
|
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
|
||||||
Cypress.Commands.add('loginViaForm', loginViaForm);
|
Cypress.Commands.add('loginViaForm', loginViaForm);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate statistic view event for given object. Useful for testing statistics pages with
|
||||||
|
* pre-generated statistics. This just generates a single "hit", but can be called multiple times to
|
||||||
|
* generate multiple hits.
|
||||||
|
*
|
||||||
|
* NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend
|
||||||
|
* (as it is in our docker-compose-ci.yml used in CI).
|
||||||
|
* Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers.
|
||||||
|
* @param uuid UUID of object
|
||||||
|
* @param dsoType type of DSpace Object (e.g. "item", "collection", "community")
|
||||||
|
*/
|
||||||
|
function generateViewEvent(uuid: string, dsoType: string): void {
|
||||||
|
// Cypress doesn't have access to the running application in Node.js.
|
||||||
|
// So, it's not possible to inject or load the AppConfig or environment of the Angular UI.
|
||||||
|
// Instead, we'll read our running application's config.json, which contains the configs &
|
||||||
|
// is regenerated at runtime each time the Angular UI application starts up.
|
||||||
|
cy.task('readUIConfig').then((str: string) => {
|
||||||
|
// Parse config into a JSON object
|
||||||
|
const config = JSON.parse(str);
|
||||||
|
|
||||||
|
// Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found.
|
||||||
|
let baseRestUrl = FALLBACK_TEST_REST_BASE_URL;
|
||||||
|
if (!config.rest.baseUrl) {
|
||||||
|
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
|
||||||
|
} else {
|
||||||
|
baseRestUrl = config.rest.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now find domain of our REST API, again with a fallback.
|
||||||
|
let baseDomain = FALLBACK_TEST_REST_DOMAIN;
|
||||||
|
if (!config.rest.host) {
|
||||||
|
console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
|
||||||
|
} else {
|
||||||
|
baseDomain = config.rest.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fake CSRF Token. Set it in the required server-side cookie
|
||||||
|
const csrfToken = 'fakeGenerateViewEventCSRFToken';
|
||||||
|
cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
|
||||||
|
|
||||||
|
// Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: baseRestUrl + '/api/statistics/viewevents',
|
||||||
|
headers: {
|
||||||
|
[XSRF_REQUEST_HEADER] : csrfToken,
|
||||||
|
// use a known public IP address to avoid being seen as a "bot"
|
||||||
|
'X-Forwarded-For': '1.1.1.1',
|
||||||
|
},
|
||||||
|
//form: true, // indicates the body should be form urlencoded
|
||||||
|
body: { targetId: uuid, targetType: dsoType },
|
||||||
|
}).then((resp) => {
|
||||||
|
// We expect a 201 (which means statistics event was created)
|
||||||
|
expect(resp.status).to.eq(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove cookie with fake CSRF token, as it's no longer needed
|
||||||
|
cy.clearCookie(DSPACE_XSRF_COOKIE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent')
|
||||||
|
Cypress.Commands.add('generateViewEvent', generateViewEvent);
|
||||||
|
|
||||||
|
@@ -30,11 +30,11 @@ beforeEach(() => {
|
|||||||
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test.
|
// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test.
|
||||||
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test.
|
// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test.
|
||||||
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
|
// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/
|
||||||
afterEach(() => {
|
/*afterEach(() => {
|
||||||
cy.window().then((win) => {
|
cy.window().then((win) => {
|
||||||
win.location.href = 'about:blank';
|
win.location.href = 'about:blank';
|
||||||
});
|
});
|
||||||
});
|
});*/
|
||||||
|
|
||||||
|
|
||||||
// Global constants used in tests
|
// Global constants used in tests
|
||||||
@@ -43,10 +43,6 @@ afterEach(() => {
|
|||||||
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
|
||||||
// (This is the data set used in our CI environment)
|
// (This is the data set used in our CI environment)
|
||||||
|
|
||||||
// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL
|
|
||||||
// from the Angular UI's config.json. See 'getBaseRESTUrl()' in commands.ts
|
|
||||||
export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server';
|
|
||||||
|
|
||||||
// Admin account used for administrative tests
|
// Admin account used for administrative tests
|
||||||
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
|
export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com';
|
||||||
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
|
export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace';
|
||||||
@@ -61,3 +57,10 @@ export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLE
|
|||||||
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
|
export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144';
|
||||||
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
|
export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com';
|
||||||
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
|
export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace';
|
||||||
|
|
||||||
|
|
||||||
|
// USEFUL REGEX for testing
|
||||||
|
|
||||||
|
// Match any string that contains at least one non-space character
|
||||||
|
// Can be used with "contains()" to determine if an element has a non-empty text value
|
||||||
|
export const REGEX_MATCH_NON_EMPTY_TEXT = /^(?!\s*$).+/;
|
@@ -6,7 +6,20 @@
|
|||||||
If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario.
|
If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario.
|
||||||
***
|
***
|
||||||
|
|
||||||
## 'Dockerfile' in root directory
|
## Overview
|
||||||
|
The scripts in this directory can be used to start the DSpace User Interface (frontend) in Docker.
|
||||||
|
Optionally, the backend (REST API) might also be started in Docker.
|
||||||
|
|
||||||
|
For additional options/settings in starting the backend (REST API) in Docker, see the Docker Compose
|
||||||
|
documentation for the backend: https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md
|
||||||
|
|
||||||
|
## Root directory
|
||||||
|
|
||||||
|
The root directory of this project contains all the Dockerfiles which may be referenced by
|
||||||
|
the Docker compose scripts in this 'docker' folder.
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
|
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -20,7 +33,18 @@ Admins to our DockerHub repo can manually publish with the following command.
|
|||||||
docker push dspace/dspace-angular:dspace-7_x
|
docker push dspace/dspace-angular:dspace-7_x
|
||||||
```
|
```
|
||||||
|
|
||||||
## docker directory
|
### Dockerfile.dist
|
||||||
|
|
||||||
|
The `Dockerfile.dist` is used to generate a *production* build and runtime environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build the latest image
|
||||||
|
docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist .
|
||||||
|
```
|
||||||
|
|
||||||
|
A default/demo version of this image is built *automatically*.
|
||||||
|
|
||||||
|
## 'docker' directory
|
||||||
- docker-compose.yml
|
- docker-compose.yml
|
||||||
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
|
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
|
||||||
- docker-compose-rest.yml
|
- docker-compose-rest.yml
|
||||||
@@ -45,23 +69,47 @@ docker-compose -f docker/docker-compose.yml build
|
|||||||
|
|
||||||
## To start DSpace (REST and Angular) from your branch
|
## 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 d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
|
docker-compose -p d7 -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.
|
||||||
|
|
||||||
|
|
||||||
## Run DSpace REST and DSpace Angular from local branches.
|
## Run DSpace REST and DSpace Angular from local branches.
|
||||||
|
|
||||||
|
This section assumes that you have clones *both* the 'DSpace/DSpace' and 'DSpace/dspace-angular' GitHub
|
||||||
|
repositories. When both are available locally, you can spin up both in Docker and have them work together.
|
||||||
|
|
||||||
_The system will be started in 2 steps. Each step shares the same docker network._
|
_The system will be started in 2 steps. Each step shares the same docker network._
|
||||||
|
|
||||||
From DSpace/DSpace (build as needed)
|
From 'DSpace/DSpace' clone (build first as needed):
|
||||||
```
|
```
|
||||||
docker-compose -p d7 up -d
|
docker-compose -p d7 up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
From DSpace/DSpace-angular
|
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 d7 -f docker/docker-compose.yml up -d
|
docker-compose -p d7 -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
At this point, you should be able to access the UI from http://localhost:4000,
|
||||||
|
and the backend at http://localhost:8080/server/
|
||||||
|
|
||||||
|
## Run DSpace Angular dist build with DSpace Demo site backend
|
||||||
|
|
||||||
|
This allows you to run the Angular UI in *production* mode, pointing it at the demo backend
|
||||||
|
(https://api7.dspace.org/server/).
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose -f docker/docker-compose-dist.yml pull
|
||||||
|
docker-compose -f docker/docker-compose-dist.yml build
|
||||||
|
docker-compose -p d7 -f docker/docker-compose-dist.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
## Ingest test data from AIPDIR
|
## Ingest test data from AIPDIR
|
||||||
|
|
||||||
Create an administrator
|
Create an administrator
|
||||||
@@ -87,9 +135,10 @@ Load assetstore content and trigger a re-index of the repository
|
|||||||
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
|
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
## End to end testing of the rest api (runs in travis).
|
## End to end testing of the REST API (runs in GitHub Actions CI).
|
||||||
_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
|
_In this instance, only the REST api runs in Docker using the Entities dataset. GitHub Actions will perform CI testing of Angular using Node to drive the tests. See `.github/workflows/build.yml` for more details._
|
||||||
|
|
||||||
|
This command is only really useful for testing our Continuous Integration process.
|
||||||
```
|
```
|
||||||
docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
|
docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d
|
||||||
```
|
```
|
||||||
|
@@ -30,6 +30,9 @@ services:
|
|||||||
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
|
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
|
||||||
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
# solr.server: Ensure we are using the 'dspacesolr' image for Solr
|
||||||
solr__P__server: http://dspacesolr:8983/solr
|
solr__P__server: http://dspacesolr:8983/solr
|
||||||
|
# Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit.
|
||||||
|
# This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
|
||||||
|
solr__D__statistics__P__autoCommit: 'false'
|
||||||
depends_on:
|
depends_on:
|
||||||
- dspacedb
|
- dspacedb
|
||||||
image: dspace/dspace:dspace-7_x-test
|
image: dspace/dspace:dspace-7_x-test
|
||||||
|
40
docker/docker-compose-dist.yml
Normal file
40
docker/docker-compose-dist.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#
|
||||||
|
# The contents of this file are subject to the license and copyright
|
||||||
|
# detailed in the LICENSE and NOTICE files at the root of the source
|
||||||
|
# tree and available online at
|
||||||
|
#
|
||||||
|
# http://www.dspace.org/license/
|
||||||
|
#
|
||||||
|
|
||||||
|
# Docker Compose for running the DSpace Angular UI dist build
|
||||||
|
# for previewing with the DSpace Demo site backend
|
||||||
|
version: '3.7'
|
||||||
|
networks:
|
||||||
|
dspacenet:
|
||||||
|
services:
|
||||||
|
dspace-angular:
|
||||||
|
container_name: dspace-angular
|
||||||
|
environment:
|
||||||
|
DSPACE_UI_SSL: 'false'
|
||||||
|
DSPACE_UI_HOST: dspace-angular
|
||||||
|
DSPACE_UI_PORT: '4000'
|
||||||
|
DSPACE_UI_NAMESPACE: /
|
||||||
|
# NOTE: When running the UI in production mode (which the -dist image does),
|
||||||
|
# these DSPACE_REST_* variables MUST point at a public, HTTPS URL.
|
||||||
|
# This is because Server Side Rendering (SSR) currently requires a public URL,
|
||||||
|
# see this bug: https://github.com/DSpace/dspace-angular/issues/1485
|
||||||
|
DSPACE_REST_SSL: 'true'
|
||||||
|
DSPACE_REST_HOST: api7.dspace.org
|
||||||
|
DSPACE_REST_PORT: 443
|
||||||
|
DSPACE_REST_NAMESPACE: /server
|
||||||
|
image: dspace/dspace-angular:dspace-7_x-dist
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile.dist
|
||||||
|
networks:
|
||||||
|
dspacenet:
|
||||||
|
ports:
|
||||||
|
- published: 4000
|
||||||
|
target: 4000
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
@@ -39,7 +39,7 @@ services:
|
|||||||
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
|
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
|
||||||
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
|
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
|
||||||
proxies__P__trusted__P__ipranges: '172.23.0'
|
proxies__P__trusted__P__ipranges: '172.23.0'
|
||||||
image: dspace/dspace:dspace-7_x-test
|
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- dspacedb
|
- dspacedb
|
||||||
networks:
|
networks:
|
||||||
@@ -82,8 +82,7 @@ services:
|
|||||||
# DSpace Solr container
|
# DSpace Solr container
|
||||||
dspacesolr:
|
dspacesolr:
|
||||||
container_name: dspacesolr
|
container_name: dspacesolr
|
||||||
# Uses official Solr image at https://hub.docker.com/_/solr/
|
image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}"
|
||||||
image: solr:8.11-slim
|
|
||||||
# Needs main 'dspace' container to start first to guarantee access to solr_configs
|
# Needs main 'dspace' container to start first to guarantee access to solr_configs
|
||||||
depends_on:
|
depends_on:
|
||||||
- dspace
|
- dspace
|
||||||
@@ -96,28 +95,26 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
working_dir: /var/solr/data
|
working_dir: /var/solr/data
|
||||||
volumes:
|
volumes:
|
||||||
# Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder)
|
|
||||||
# This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume
|
|
||||||
- solr_configs:/opt/solr/server/solr/configsets/dspace
|
|
||||||
# Keep Solr data directory between reboots
|
# Keep Solr data directory between reboots
|
||||||
- solr_data:/var/solr/data
|
- solr_data:/var/solr/data
|
||||||
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
|
# Initialize all DSpace Solr cores using the mounted local configsets (see above), then start Solr
|
||||||
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
|
# * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op
|
||||||
# * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core
|
# * Second, copy configsets to this core:
|
||||||
# to the latest configs. If it's a newly created core, this is a no-op.
|
# 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`
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /bin/bash
|
- /bin/bash
|
||||||
- '-c'
|
- '-c'
|
||||||
- |
|
- |
|
||||||
init-var-solr
|
init-var-solr
|
||||||
precreate-core authority /opt/solr/server/solr/configsets/dspace/authority
|
precreate-core authority /opt/solr/server/solr/configsets/authority
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority
|
cp -r /opt/solr/server/solr/configsets/authority/* authority
|
||||||
precreate-core oai /opt/solr/server/solr/configsets/dspace/oai
|
precreate-core oai /opt/solr/server/solr/configsets/oai
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai
|
cp -r /opt/solr/server/solr/configsets/oai/* oai
|
||||||
precreate-core search /opt/solr/server/solr/configsets/dspace/search
|
precreate-core search /opt/solr/server/solr/configsets/search
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search
|
cp -r /opt/solr/server/solr/configsets/search/* search
|
||||||
precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics
|
precreate-core statistics /opt/solr/server/solr/configsets/statistics
|
||||||
cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics
|
cp -r /opt/solr/server/solr/configsets/statistics/* statistics
|
||||||
exec solr -f
|
exec solr -f
|
||||||
volumes:
|
volumes:
|
||||||
assetstore:
|
assetstore:
|
||||||
|
11
docker/dspace-ui.json
Normal file
11
docker/dspace-ui.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "dspace-ui",
|
||||||
|
"cwd": "/app",
|
||||||
|
"script": "dist/server/main.js",
|
||||||
|
"instances": "max",
|
||||||
|
"exec_mode": "cluster"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
165
package.json
165
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dspace-angular",
|
"name": "dspace-angular",
|
||||||
"version": "7.5.0-next",
|
"version": "7.6.0-next",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"config:watch": "nodemon",
|
"config:watch": "nodemon",
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
"build:stats": "ng build --stats-json",
|
"build:stats": "ng build --stats-json",
|
||||||
"build:prod": "yarn run build:ssr",
|
"build:prod": "yarn run build:ssr",
|
||||||
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
|
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
|
||||||
"test": "ng test --sourceMap=true --watch=false --configuration test",
|
"test": "ng test --source-map=true --watch=false --configuration test",
|
||||||
"test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"",
|
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
|
||||||
"test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
|
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"lint-fix": "ng lint --fix=true",
|
"lint-fix": "ng lint --fix=true",
|
||||||
"e2e": "ng e2e",
|
"e2e": "ng e2e",
|
||||||
@@ -55,131 +55,136 @@
|
|||||||
"ts-node": "10.2.1"
|
"ts-node": "10.2.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~13.3.12",
|
"@angular/animations": "^15.2.8",
|
||||||
"@angular/cdk": "^13.2.6",
|
"@angular/cdk": "^15.2.8",
|
||||||
"@angular/common": "~13.3.12",
|
"@angular/common": "^15.2.8",
|
||||||
"@angular/compiler": "~13.3.12",
|
"@angular/compiler": "^15.2.8",
|
||||||
"@angular/core": "~13.3.12",
|
"@angular/core": "^15.2.8",
|
||||||
"@angular/forms": "~13.3.12",
|
"@angular/forms": "^15.2.8",
|
||||||
"@angular/localize": "13.3.12",
|
"@angular/localize": "15.2.8",
|
||||||
"@angular/platform-browser": "~13.3.12",
|
"@angular/platform-browser": "^15.2.8",
|
||||||
"@angular/platform-browser-dynamic": "~13.3.12",
|
"@angular/platform-browser-dynamic": "^15.2.8",
|
||||||
"@angular/platform-server": "~13.3.12",
|
"@angular/platform-server": "^15.2.8",
|
||||||
"@angular/router": "~13.3.12",
|
"@angular/router": "^15.2.8",
|
||||||
"@babel/runtime": "7.17.2",
|
"@babel/runtime": "7.21.0",
|
||||||
"@kolkov/ngx-gallery": "^2.0.1",
|
"@kolkov/ngx-gallery": "^2.0.1",
|
||||||
"@material-ui/core": "^4.11.0",
|
"@material-ui/core": "^4.11.0",
|
||||||
"@material-ui/icons": "^4.9.1",
|
"@material-ui/icons": "^4.11.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
|
||||||
"@ng-dynamic-forms/core": "^15.0.0",
|
"@ng-dynamic-forms/core": "^15.0.0",
|
||||||
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
|
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
|
||||||
"@ngrx/effects": "^13.0.2",
|
"@ngrx/effects": "^15.4.0",
|
||||||
"@ngrx/router-store": "^13.0.2",
|
"@ngrx/router-store": "^15.4.0",
|
||||||
"@ngrx/store": "^13.0.2",
|
"@ngrx/store": "^15.4.0",
|
||||||
"@nguniversal/express-engine": "^13.0.2",
|
"@nguniversal/express-engine": "^15.2.1",
|
||||||
"@ngx-translate/core": "^13.0.0",
|
"@ngx-translate/core": "^14.0.0",
|
||||||
"@nicky-lenaers/ngx-scroll-to": "^13.0.0",
|
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
"angular-idle-preload": "3.0.0",
|
"angular-idle-preload": "3.0.0",
|
||||||
"angulartics2": "^12.0.0",
|
"angulartics2": "^12.2.0",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"bootstrap": "^4.6.1",
|
"bootstrap": "^4.6.1",
|
||||||
"cerialize": "0.1.18",
|
"cerialize": "0.1.18",
|
||||||
"cli-progress": "^3.8.0",
|
"cli-progress": "^3.12.0",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cookie-parser": "1.4.5",
|
"cookie-parser": "1.4.6",
|
||||||
"core-js": "^3.7.0",
|
"core-js": "^3.30.1",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"date-fns-tz": "^1.3.7",
|
"date-fns-tz": "^1.3.7",
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.3.1",
|
||||||
"express": "^4.17.1",
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^5.1.3",
|
"express-rate-limit": "^5.1.3",
|
||||||
"fast-json-patch": "^3.0.0-1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"filesize": "^6.1.0",
|
"filesize": "^6.1.0",
|
||||||
"http-proxy-middleware": "^1.0.5",
|
"http-proxy-middleware": "^1.0.5",
|
||||||
|
"isbot": "^3.6.10",
|
||||||
"js-cookie": "2.2.1",
|
"js-cookie": "2.2.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json5": "^2.2.2",
|
"json5": "^2.2.3",
|
||||||
"jsonschema": "1.4.0",
|
"jsonschema": "1.4.1",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"klaro": "^0.7.18",
|
"klaro": "^0.7.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"lru-cache": "^7.14.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"markdown-it-mathjax3": "^4.3.1",
|
"markdown-it-mathjax3": "^4.3.2",
|
||||||
"mirador": "^3.3.0",
|
"mirador": "^3.3.0",
|
||||||
"mirador-dl-plugin": "^0.13.0",
|
"mirador-dl-plugin": "^0.13.0",
|
||||||
"mirador-share-plugin": "^0.11.0",
|
"mirador-share-plugin": "^0.11.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"ng-mocks": "^13.1.1",
|
"ng-mocks": "^14.10.0",
|
||||||
"ng2-file-upload": "1.4.0",
|
"ng2-file-upload": "1.4.0",
|
||||||
"ng2-nouislider": "^1.8.3",
|
"ng2-nouislider": "^1.8.3",
|
||||||
"ngx-infinite-scroll": "^10.0.1",
|
"ngx-infinite-scroll": "^15.0.0",
|
||||||
"ngx-pagination": "5.0.0",
|
"ngx-pagination": "6.0.3",
|
||||||
"ngx-sortablejs": "^11.1.0",
|
"ngx-sortablejs": "^11.1.0",
|
||||||
"ngx-ui-switch": "^13.0.2",
|
"ngx-ui-switch": "^14.0.3",
|
||||||
"nouislider": "^14.6.3",
|
"nouislider": "^14.6.3",
|
||||||
"pem": "1.14.4",
|
"pem": "1.14.7",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.8.1",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.5.5",
|
"rxjs": "^7.8.0",
|
||||||
"sanitize-html": "^2.7.2",
|
"sanitize-html": "^2.10.0",
|
||||||
"sortablejs": "1.13.0",
|
"sortablejs": "1.15.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webfontloader": "1.6.28",
|
"webfontloader": "1.6.28",
|
||||||
"zone.js": "~0.11.5"
|
"zone.js": "~0.11.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "~13.1.0",
|
"@angular-builders/custom-webpack": "~15.0.0",
|
||||||
"@angular-devkit/build-angular": "~13.3.10",
|
"@angular-devkit/build-angular": "^15.2.6",
|
||||||
"@angular-eslint/builder": "13.1.0",
|
"@angular-eslint/builder": "15.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "13.1.0",
|
"@angular-eslint/eslint-plugin": "15.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "13.1.0",
|
"@angular-eslint/eslint-plugin-template": "15.2.1",
|
||||||
"@angular-eslint/schematics": "13.1.0",
|
"@angular-eslint/schematics": "15.2.1",
|
||||||
"@angular-eslint/template-parser": "13.1.0",
|
"@angular-eslint/template-parser": "15.2.1",
|
||||||
"@angular/cli": "~13.3.10",
|
"@angular/cli": "^15.2.6",
|
||||||
"@angular/compiler-cli": "~13.3.12",
|
"@angular/compiler-cli": "^15.2.8",
|
||||||
"@angular/language-service": "~13.3.12",
|
"@angular/language-service": "^15.2.8",
|
||||||
"@cypress/schematic": "^1.5.0",
|
"@cypress/schematic": "^1.5.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.2.1",
|
"@fortawesome/fontawesome-free": "^6.4.0",
|
||||||
"@ngrx/store-devtools": "^13.0.2",
|
"@ngrx/store-devtools": "^15.4.0",
|
||||||
"@ngtools/webpack": "^13.2.6",
|
"@ngtools/webpack": "^15.2.6",
|
||||||
"@nguniversal/builders": "^13.1.1",
|
"@nguniversal/builders": "^15.2.1",
|
||||||
"@types/deep-freeze": "0.1.2",
|
"@types/deep-freeze": "0.1.2",
|
||||||
"@types/express": "^4.17.9",
|
"@types/ejs": "^3.1.2",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
"@types/lodash": "^4.14.165",
|
"@types/lodash": "^4.14.194",
|
||||||
"@types/node": "^14.14.9",
|
"@types/node": "^14.14.9",
|
||||||
"@types/sanitize-html": "^2.6.2",
|
"@types/sanitize-html": "^2.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.11.0",
|
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||||
"@typescript-eslint/parser": "5.11.0",
|
"@typescript-eslint/parser": "^5.59.1",
|
||||||
"axe-core": "^4.4.3",
|
"axe-core": "^4.7.0",
|
||||||
"compression-webpack-plugin": "^9.2.0",
|
"compression-webpack-plugin": "^9.2.0",
|
||||||
"copy-webpack-plugin": "^6.4.1",
|
"copy-webpack-plugin": "^6.4.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"cypress": "9.7.0",
|
"cypress": "12.10.0",
|
||||||
"cypress-axe": "^0.14.0",
|
"cypress-axe": "^1.4.0",
|
||||||
"deep-freeze": "0.0.1",
|
"deep-freeze": "0.0.1",
|
||||||
"eslint": "^8.2.0",
|
"eslint": "^8.39.0",
|
||||||
"eslint-plugin-deprecation": "^1.3.2",
|
"eslint-plugin-deprecation": "^1.4.1",
|
||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"eslint-plugin-jsdoc": "^39.6.4",
|
"eslint-plugin-jsdoc": "^39.6.4",
|
||||||
|
"eslint-plugin-jsonc": "^2.6.0",
|
||||||
"eslint-plugin-lodash": "^7.4.0",
|
"eslint-plugin-lodash": "^7.4.0",
|
||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"express-static-gzip": "^2.1.5",
|
"express-static-gzip": "^2.1.7",
|
||||||
"jasmine-core": "^3.8.0",
|
"jasmine-core": "^3.8.0",
|
||||||
"jasmine-marbles": "0.9.2",
|
"jasmine-marbles": "0.9.2",
|
||||||
"karma": "^6.3.14",
|
"karma": "^6.4.2",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage-istanbul-reporter": "~3.0.2",
|
"karma-coverage-istanbul-reporter": "~3.0.3",
|
||||||
"karma-jasmine": "~4.0.0",
|
"karma-jasmine": "~4.0.0",
|
||||||
"karma-jasmine-html-reporter": "^1.5.0",
|
"karma-jasmine-html-reporter": "^1.5.0",
|
||||||
"karma-mocha-reporter": "2.2.5",
|
"karma-mocha-reporter": "2.2.5",
|
||||||
"ngx-mask": "^13.1.7",
|
"ngx-mask": "^13.1.7",
|
||||||
"nodemon": "^2.0.20",
|
"nodemon": "^2.0.22",
|
||||||
"postcss": "^8.1",
|
"postcss": "^8.4",
|
||||||
"postcss-apply": "0.12.0",
|
"postcss-apply": "0.12.0",
|
||||||
"postcss-import": "^14.0.0",
|
"postcss-import": "^14.0.0",
|
||||||
"postcss-loader": "^4.0.3",
|
"postcss-loader": "^4.0.3",
|
||||||
@@ -189,14 +194,14 @@
|
|||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rxjs-spy": "^8.0.2",
|
"rxjs-spy": "^8.0.2",
|
||||||
"sass": "~1.33.0",
|
"sass": "~1.62.0",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^12.6.0",
|
||||||
"sass-resources-loader": "^2.1.1",
|
"sass-resources-loader": "^2.2.5",
|
||||||
"ts-node": "^8.10.2",
|
"ts-node": "^8.10.2",
|
||||||
"typescript": "~4.5.5",
|
"typescript": "~4.8.4",
|
||||||
"webpack": "^5.69.1",
|
"webpack": "5.76.1",
|
||||||
"webpack-bundle-analyzer": "^4.4.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
"webpack-cli": "^4.2.0",
|
"webpack-cli": "^4.2.0",
|
||||||
"webpack-dev-server": "^4.5.0"
|
"webpack-dev-server": "^4.13.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
320
server.ts
320
server.ts
@@ -22,11 +22,14 @@ import 'rxjs';
|
|||||||
/* eslint-disable import/no-namespace */
|
/* eslint-disable import/no-namespace */
|
||||||
import * as morgan from 'morgan';
|
import * as morgan from 'morgan';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
|
import * as ejs from 'ejs';
|
||||||
import * as compression from 'compression';
|
import * as compression from 'compression';
|
||||||
import * as expressStaticGzip from 'express-static-gzip';
|
import * as expressStaticGzip from 'express-static-gzip';
|
||||||
/* eslint-enable import/no-namespace */
|
/* eslint-enable import/no-namespace */
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import LRU from 'lru-cache';
|
||||||
|
import isbot from 'isbot';
|
||||||
import { createCertificate } from 'pem';
|
import { createCertificate } from 'pem';
|
||||||
import { createServer } from 'https';
|
import { createServer } from 'https';
|
||||||
import { json } from 'body-parser';
|
import { json } from 'body-parser';
|
||||||
@@ -34,7 +37,6 @@ import { json } from 'body-parser';
|
|||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
|
||||||
import { enableProdMode } from '@angular/core';
|
import { enableProdMode } from '@angular/core';
|
||||||
|
|
||||||
import { ngExpressEngine } from '@nguniversal/express-engine';
|
import { ngExpressEngine } from '@nguniversal/express-engine';
|
||||||
@@ -52,6 +54,8 @@ import { buildAppConfig } from './src/config/config.server';
|
|||||||
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
|
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
|
||||||
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
||||||
import { logStartupMessage } from './startup-message';
|
import { logStartupMessage } from './startup-message';
|
||||||
|
import { TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set path for the browser application's dist folder
|
* Set path for the browser application's dist folder
|
||||||
@@ -60,12 +64,18 @@ const DIST_FOLDER = join(process.cwd(), 'dist/browser');
|
|||||||
// Set path fir IIIF viewer.
|
// Set path fir IIIF viewer.
|
||||||
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
|
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
|
||||||
|
|
||||||
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
|
const indexHtml = join(DIST_FOLDER, 'index.html');
|
||||||
|
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
|
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
|
||||||
|
|
||||||
|
// cache of SSR pages for known bots, only enabled in production mode
|
||||||
|
let botCache: LRU<string, any>;
|
||||||
|
|
||||||
|
// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
|
||||||
|
let anonymousCache: LRU<string, any>;
|
||||||
|
|
||||||
// extend environment with app config for server
|
// extend environment with app config for server
|
||||||
extendEnvironmentWithAppConfig(environment, appConfig);
|
extendEnvironmentWithAppConfig(environment, appConfig);
|
||||||
|
|
||||||
@@ -86,10 +96,12 @@ export function app() {
|
|||||||
/*
|
/*
|
||||||
* If production mode is enabled in the environment file:
|
* If production mode is enabled in the environment file:
|
||||||
* - Enable Angular's production mode
|
* - 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 reponses. See [compression](https://github.com/expressjs/compression)
|
||||||
*/
|
*/
|
||||||
if (environment.production) {
|
if (environment.production) {
|
||||||
enableProdMode();
|
enableProdMode();
|
||||||
|
initCache();
|
||||||
server.use(compression({
|
server.use(compression({
|
||||||
// only compress responses we've marked as SSR
|
// only compress responses we've marked as SSR
|
||||||
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
||||||
@@ -105,13 +117,13 @@ export function app() {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Add cookie parser middleware
|
* Add cookie parser middleware
|
||||||
* See [morgan](https://github.com/expressjs/cookie-parser)
|
* See [cookie-parser](https://github.com/expressjs/cookie-parser)
|
||||||
*/
|
*/
|
||||||
server.use(cookieParser());
|
server.use(cookieParser());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Add parser for request bodies
|
* Add JSON parser for request bodies
|
||||||
* See [morgan](https://github.com/expressjs/body-parser)
|
* See [body-parser](https://github.com/expressjs/body-parser)
|
||||||
*/
|
*/
|
||||||
server.use(json());
|
server.use(json());
|
||||||
|
|
||||||
@@ -136,10 +148,23 @@ export function app() {
|
|||||||
})(_, (options as any), callback)
|
})(_, (options as any), callback)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.engine('ejs', ejs.renderFile);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Register the view engines for html and ejs
|
* Register the view engines for html and ejs
|
||||||
*/
|
*/
|
||||||
server.set('view engine', 'html');
|
server.set('view engine', 'html');
|
||||||
|
server.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the robots.txt ejs template, filling in the origin variable
|
||||||
|
*/
|
||||||
|
server.get('/robots.txt', (req, res) => {
|
||||||
|
res.setHeader('content-type', 'text/plain');
|
||||||
|
res.render('assets/robots.txt.ejs', {
|
||||||
|
'origin': req.protocol + '://' + req.headers.host
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set views folder path to directory where template files are stored
|
* Set views folder path to directory where template files are stored
|
||||||
@@ -172,7 +197,7 @@ export function app() {
|
|||||||
* Serve static resources (images, i18n messages, …)
|
* Serve static resources (images, i18n messages, …)
|
||||||
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
|
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
|
||||||
*/
|
*/
|
||||||
router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
|
router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
|
||||||
index: false,
|
index: false,
|
||||||
enableBrotli: true,
|
enableBrotli: true,
|
||||||
orderPreference: ['br', 'gzip'],
|
orderPreference: ['br', 'gzip'],
|
||||||
@@ -188,8 +213,11 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
server.get('/app/health', healthCheck);
|
server.get('/app/health', healthCheck);
|
||||||
|
|
||||||
// Register the ngApp callback function to handle incoming requests
|
/**
|
||||||
router.get('*', ngApp);
|
* Default sending all incoming requests to ngApp() function, after first checking for a cached
|
||||||
|
* copy of the page (see cacheCheck())
|
||||||
|
*/
|
||||||
|
router.get('*', cacheCheck, ngApp);
|
||||||
|
|
||||||
server.use(environment.ui.nameSpace, router);
|
server.use(environment.ui.nameSpace, router);
|
||||||
|
|
||||||
@@ -201,60 +229,242 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
function ngApp(req, res) {
|
function ngApp(req, res) {
|
||||||
if (environment.universal.preboot) {
|
if (environment.universal.preboot) {
|
||||||
res.render(indexHtml, {
|
// Render the page to user via SSR (server side rendering)
|
||||||
req,
|
serverSideRender(req, res);
|
||||||
res,
|
|
||||||
preboot: environment.universal.preboot,
|
|
||||||
async: environment.universal.async,
|
|
||||||
time: environment.universal.time,
|
|
||||||
baseUrl: environment.ui.nameSpace,
|
|
||||||
originUrl: environment.ui.baseUrl,
|
|
||||||
requestUrl: req.originalUrl,
|
|
||||||
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
|
||||||
}, (err, data) => {
|
|
||||||
if (hasNoValue(err) && hasValue(data)) {
|
|
||||||
res.locals.ssr = true; // mark response as SSR
|
|
||||||
res.send(data);
|
|
||||||
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
|
||||||
// When this error occurs we can't fall back to CSR because the response has already been
|
|
||||||
// sent. These errors occur for various reasons in universal, not all of which are in our
|
|
||||||
// control to solve.
|
|
||||||
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
|
||||||
} else {
|
|
||||||
console.warn('Error in SSR, serving for direct CSR.');
|
|
||||||
if (hasValue(err)) {
|
|
||||||
console.warn('Error details : ', err);
|
|
||||||
}
|
|
||||||
res.render(indexHtml, {
|
|
||||||
req,
|
|
||||||
providers: [{
|
|
||||||
provide: APP_BASE_HREF,
|
|
||||||
useValue: req.baseUrl
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// If preboot is disabled, just serve the client
|
// If preboot is disabled, just serve the client
|
||||||
console.log('Universal off, serving for direct CSR');
|
console.log('Universal off, serving for direct client-side rendering (CSR)');
|
||||||
res.render(indexHtml, {
|
clientSideRender(req, res);
|
||||||
req,
|
}
|
||||||
providers: [{
|
}
|
||||||
provide: APP_BASE_HREF,
|
|
||||||
useValue: req.baseUrl
|
/**
|
||||||
}]
|
* Render page content on server side using Angular SSR. By default this page content is
|
||||||
|
* returned to the user.
|
||||||
|
* @param req current request
|
||||||
|
* @param res current response
|
||||||
|
* @param sendToUser if true (default), send the rendered content to the user.
|
||||||
|
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
|
||||||
|
*/
|
||||||
|
function serverSideRender(req, res, sendToUser: boolean = true) {
|
||||||
|
// Render the page via SSR (server side rendering)
|
||||||
|
res.render(indexHtml, {
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
preboot: environment.universal.preboot,
|
||||||
|
async: environment.universal.async,
|
||||||
|
time: environment.universal.time,
|
||||||
|
baseUrl: environment.ui.nameSpace,
|
||||||
|
originUrl: environment.ui.baseUrl,
|
||||||
|
requestUrl: req.originalUrl,
|
||||||
|
}, (err, data) => {
|
||||||
|
if (hasNoValue(err) && hasValue(data)) {
|
||||||
|
// save server side rendered page to cache (if any are enabled)
|
||||||
|
saveToCache(req, data);
|
||||||
|
if (sendToUser) {
|
||||||
|
res.locals.ssr = true; // mark response as SSR (enables text compression)
|
||||||
|
// send rendered page to user
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
||||||
|
// When this error occurs we can't fall back to CSR because the response has already been
|
||||||
|
// sent. These errors occur for various reasons in universal, not all of which are in our
|
||||||
|
// control to solve.
|
||||||
|
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
||||||
|
} else {
|
||||||
|
console.warn('Error in server-side rendering (SSR)');
|
||||||
|
if (hasValue(err)) {
|
||||||
|
console.warn('Error details : ', err);
|
||||||
|
}
|
||||||
|
if (sendToUser) {
|
||||||
|
console.warn('Falling back to serving direct client-side rendering (CSR).');
|
||||||
|
clientSideRender(req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send back response to user to trigger direct client-side rendering (CSR)
|
||||||
|
* @param req current request
|
||||||
|
* @param res current response
|
||||||
|
*/
|
||||||
|
function clientSideRender(req, res) {
|
||||||
|
res.sendFile(indexHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Adds a Cache-Control HTTP header to the response.
|
||||||
|
* The cache control value can be configured in the config.*.yml file
|
||||||
|
* Defaults to max-age=604,800 seconds (1 week)
|
||||||
|
*/
|
||||||
|
function addCacheControl(req, res, next) {
|
||||||
|
// instruct browser to revalidate
|
||||||
|
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Initialize server-side caching of pages rendered via SSR.
|
||||||
|
*/
|
||||||
|
function initCache() {
|
||||||
|
if (botCacheEnabled()) {
|
||||||
|
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
|
||||||
|
// See https://www.npmjs.com/package/lru-cache
|
||||||
|
// When enabled, each page defaults to expiring after 1 day
|
||||||
|
botCache = new LRU( {
|
||||||
|
max: environment.cache.serverSide.botCache.max,
|
||||||
|
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
|
||||||
|
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anonymousCacheEnabled()) {
|
||||||
|
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
|
||||||
|
// may expire pages more frequently.
|
||||||
|
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
|
||||||
|
anonymousCache = new LRU( {
|
||||||
|
max: environment.cache.serverSide.anonymousCache.max,
|
||||||
|
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
|
||||||
|
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* Adds a cache control header to the response
|
* Return whether bot-specific server side caching is enabled in configuration.
|
||||||
* The cache control value can be configured in the environments file and defaults to max-age=60
|
|
||||||
*/
|
*/
|
||||||
function cacheControl(req, res, next) {
|
function botCacheEnabled(): boolean {
|
||||||
// instruct browser to revalidate
|
// Caching is only enabled if SSR is enabled AND
|
||||||
res.header('Cache-Control', environment.cache.control || 'max-age=60');
|
// "max" pages to cache is greater than zero
|
||||||
next();
|
return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether anonymous user server side caching is enabled in configuration.
|
||||||
|
*/
|
||||||
|
function anonymousCacheEnabled(): boolean {
|
||||||
|
// Caching is only enabled if SSR is enabled AND
|
||||||
|
// "max" pages to cache is greater than zero
|
||||||
|
return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the currently requested page is in our server-side, in-memory cache.
|
||||||
|
* Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
|
||||||
|
*/
|
||||||
|
function cacheCheck(req, res, next) {
|
||||||
|
// Cached copy of page (if found)
|
||||||
|
let cachedCopy;
|
||||||
|
|
||||||
|
// If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
|
||||||
|
if (botCacheEnabled() && isbot(req.get('user-agent'))) {
|
||||||
|
cachedCopy = checkCacheForRequest('bot', botCache, req, res);
|
||||||
|
} else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
|
||||||
|
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cached copy exists, return it to the user.
|
||||||
|
if (cachedCopy) {
|
||||||
|
res.locals.ssr = true; // mark response as SSR-generated (enables text compression)
|
||||||
|
res.send(cachedCopy);
|
||||||
|
|
||||||
|
// Tell Express to skip all other handlers for this path
|
||||||
|
// This ensures we don't try to re-render the page since we've already returned the cached copy
|
||||||
|
next('router');
|
||||||
|
} else {
|
||||||
|
// If nothing found in cache, just continue with next handler
|
||||||
|
// (This should send the request on to the handler that rerenders the page via SSR
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current request (i.e. page) is found in the given cache. If it is found,
|
||||||
|
* the cached copy is returned. When found, this method also triggers a re-render via
|
||||||
|
* SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy).
|
||||||
|
* @param cacheName name of cache (just useful for debug logging)
|
||||||
|
* @param cache LRU cache to check
|
||||||
|
* @param req current request to look for in the cache
|
||||||
|
* @param res current response
|
||||||
|
* @returns cached copy (if found) or undefined (if not found)
|
||||||
|
*/
|
||||||
|
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res): any {
|
||||||
|
// Get the cache key for this request
|
||||||
|
const key = getCacheKey(req);
|
||||||
|
|
||||||
|
// Check if this page is in our cache
|
||||||
|
let cachedCopy = cache.get(key);
|
||||||
|
if (cachedCopy) {
|
||||||
|
if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
|
||||||
|
|
||||||
|
// Check if cached copy is expired (If expired, the key will now be gone from cache)
|
||||||
|
// NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value.
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
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.
|
||||||
|
serverSideRender(req, res, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// return page from cache
|
||||||
|
return cachedCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a cache key from the current request.
|
||||||
|
* The cache key is the URL path (NOTE: this key will also include any querystring params).
|
||||||
|
* E.g. "/home" or "/search?query=test"
|
||||||
|
* @param req current request
|
||||||
|
* @returns cache key to use for this page
|
||||||
|
*/
|
||||||
|
function getCacheKey(req): string {
|
||||||
|
// NOTE: this will return the URL path *without* any baseUrl
|
||||||
|
return req.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop
|
||||||
|
* If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired).
|
||||||
|
* (This minimizes the number of times we need to run SSR on the same page.)
|
||||||
|
* @param req current page request
|
||||||
|
* @param page page data to save to cache
|
||||||
|
*/
|
||||||
|
function saveToCache(req, page: any) {
|
||||||
|
// Only cache if no one is currently authenticated. This means ONLY public pages can be cached.
|
||||||
|
// NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation,
|
||||||
|
// the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info.
|
||||||
|
if (!isUserAuthenticated(req)) {
|
||||||
|
const key = getCacheKey(req);
|
||||||
|
// Avoid caching "/reload/[random]" paths (these are hard refreshes after logout)
|
||||||
|
if (key.startsWith('/reload')) { return; }
|
||||||
|
|
||||||
|
// If bot cache is enabled, save it to that cache if it doesn't exist or is expired
|
||||||
|
// (NOTE: has() will return false if page is expired in cache)
|
||||||
|
if (botCacheEnabled() && !botCache.has(key)) {
|
||||||
|
botCache.set(key, page);
|
||||||
|
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired
|
||||||
|
if (anonymousCacheEnabled() && !anonymousCache.has(key)) {
|
||||||
|
anonymousCache.set(key, page);
|
||||||
|
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a user is authenticated or not
|
||||||
|
*/
|
||||||
|
function isUserAuthenticated(req): boolean {
|
||||||
|
// Check whether our DSpace authentication Cookie exists or not
|
||||||
|
return req.cookies[TOKENITEM];
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@@ -27,7 +27,10 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
|
|||||||
SharedModule,
|
SharedModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
AccessControlRoutingModule,
|
AccessControlRoutingModule,
|
||||||
FormModule
|
FormModule,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
MembersListComponent,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
EPeopleRegistryComponent,
|
EPeopleRegistryComponent,
|
||||||
@@ -35,7 +38,7 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
|
|||||||
GroupsRegistryComponent,
|
GroupsRegistryComponent,
|
||||||
GroupFormComponent,
|
GroupFormComponent,
|
||||||
SubgroupsListComponent,
|
SubgroupsListComponent,
|
||||||
MembersListComponent
|
MembersListComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
<button class="mr-auto btn btn-success addEPerson-button"
|
<button class="mr-auto btn btn-success addEPerson-button"
|
||||||
(click)="isEPersonFormShown = true">
|
(click)="isEPersonFormShown = true">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
<span class="d-none d-sm-inline">{{labelPrefix + 'button.add' | translate}}</span>
|
<span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="flex-grow-1 mr-3 ml-3">
|
<div class="flex-grow-1 mr-3 ml-3">
|
||||||
<div class="form-group input-group">
|
<div class="form-group input-group">
|
||||||
<input type="text" name="query" id="query" formControlName="query"
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
class="form-control" attr.aria-label="{{labelPrefix + 'search.placeholder' | translate}}"
|
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
|
||||||
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
|
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button type="submit" class="search-button btn btn-primary">
|
<button type="submit" class="search-button btn btn-primary">
|
||||||
@@ -68,18 +68,18 @@
|
|||||||
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
||||||
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
||||||
<td>{{epersonDto.eperson.id}}</td>
|
<td>{{epersonDto.eperson.id}}</td>
|
||||||
<td>{{epersonDto.eperson.name}}</td>
|
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
|
||||||
<td>{{epersonDto.eperson.email}}</td>
|
<td>{{epersonDto.eperson.email}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)"
|
<button (click)="toggleEditEPerson(epersonDto.eperson)"
|
||||||
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
||||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
|
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
||||||
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
||||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
|
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Observable, of as observableOf } from 'rxjs';
|
import { Observable, of as observableOf } from 'rxjs';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
@@ -42,6 +42,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
let paginationService;
|
let paginationService;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
jasmine.getEnv().allowRespy(true);
|
||||||
mockEPeople = [EPersonMock, EPersonMock2];
|
mockEPeople = [EPersonMock, EPersonMock2];
|
||||||
ePersonDataServiceStub = {
|
ePersonDataServiceStub = {
|
||||||
activeEPerson: null,
|
activeEPerson: null,
|
||||||
@@ -98,7 +99,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
deleteEPerson(ePerson: EPerson): Observable<boolean> {
|
deleteEPerson(ePerson: EPerson): Observable<boolean> {
|
||||||
this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => {
|
this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => {
|
||||||
return (ePerson2.uuid !== ePerson.uuid);
|
return (ePerson2.uuid !== ePerson.uuid);
|
||||||
});
|
});
|
||||||
return observableOf(true);
|
return observableOf(true);
|
||||||
},
|
},
|
||||||
editEPerson(ePerson: EPerson) {
|
editEPerson(ePerson: EPerson) {
|
||||||
@@ -260,17 +261,16 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
describe('delete EPerson button when the isAuthorized returns false', () => {
|
describe('delete EPerson button when the isAuthorized returns false', () => {
|
||||||
let ePeopleDeleteButton;
|
let ePeopleDeleteButton;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false));
|
||||||
isAuthorized: observableOf(false)
|
component.initialisePage();
|
||||||
});
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be disabled', () => {
|
it('should be disabled', () => {
|
||||||
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
|
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
|
||||||
ePeopleDeleteButton.forEach((deleteButton) => {
|
ePeopleDeleteButton.forEach((deleteButton: DebugElement) => {
|
||||||
expect(deleteButton.nativeElement.disabled).toBe(true);
|
expect(deleteButton.nativeElement.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
||||||
@@ -21,6 +21,7 @@ import { RequestService } from '../../core/data/request.service';
|
|||||||
import { PageInfo } from '../../core/shared/page-info.model';
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
import { NoContent } from '../../core/shared/NoContent.model';
|
import { NoContent } from '../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-epeople-registry',
|
selector: 'ds-epeople-registry',
|
||||||
@@ -89,11 +90,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private authorizationService: AuthorizationDataService,
|
private authorizationService: AuthorizationDataService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: UntypedFormBuilder,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
public requestService: RequestService) {
|
public requestService: RequestService,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
this.currentSearchQuery = '';
|
this.currentSearchQuery = '';
|
||||||
this.currentSearchScope = 'metadata';
|
this.currentSearchScope = 'metadata';
|
||||||
this.searchForm = this.formBuilder.group(({
|
this.searchForm = this.formBuilder.group(({
|
||||||
@@ -121,7 +124,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
this.subs.push(this.ePeople$.pipe(
|
this.subs.push(this.ePeople$.pipe(
|
||||||
switchMap((epeople: PaginatedList<EPerson>) => {
|
switchMap((epeople: PaginatedList<EPerson>) => {
|
||||||
if (epeople.pageInfo.totalElements > 0) {
|
if (epeople.pageInfo.totalElements > 0) {
|
||||||
return combineLatest(...epeople.page.map((eperson) => {
|
return combineLatest([...epeople.page.map((eperson: EPerson) => {
|
||||||
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
|
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
|
||||||
map((authorized) => {
|
map((authorized) => {
|
||||||
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
||||||
@@ -130,7 +133,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
return epersonDtoModel;
|
return epersonDtoModel;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})).pipe(map((dtos: EpersonDtoModel[]) => {
|
})]).pipe(map((dtos: EpersonDtoModel[]) => {
|
||||||
return buildPaginatedList(epeople.pageInfo, dtos);
|
return buildPaginatedList(epeople.pageInfo, dtos);
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@@ -237,7 +240,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
|||||||
if (hasValue(ePerson.id)) {
|
if (hasValue(ePerson.id)) {
|
||||||
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||||
if (restResponse.hasSucceeded) {
|
if (restResponse.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name}));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)}));
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
||||||
}
|
}
|
||||||
|
@@ -13,12 +13,13 @@
|
|||||||
[formGroup]="formGroup"
|
[formGroup]="formGroup"
|
||||||
[formLayout]="formLayout"
|
[formLayout]="formLayout"
|
||||||
[displayCancel]="false"
|
[displayCancel]="false"
|
||||||
|
[submitLabel]="submitLabel"
|
||||||
(submitForm)="onSubmit()">
|
(submitForm)="onSubmit()">
|
||||||
<div before class="btn-group">
|
<div before class="btn-group">
|
||||||
<button (click)="onCancel()"
|
<button (click)="onCancel()"
|
||||||
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
|
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div between class="btn-group">
|
<div *ngIf="displayResetPassword" between class="btn-group">
|
||||||
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
|
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" (click)="resetPassword()">
|
||||||
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
||||||
</button>
|
</button>
|
||||||
@@ -64,9 +65,13 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||||
<td class="align-middle">{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
|
<td class="align-middle">
|
||||||
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
<a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
|
||||||
|
{{ dsoNameService.getName(group) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@@ -2,7 +2,7 @@ import { Observable, of as observableOf } from 'rxjs';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -116,9 +116,9 @@ describe('EPersonFormComponent', () => {
|
|||||||
const controlModel = model;
|
const controlModel = model;
|
||||||
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
||||||
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
||||||
controls[model.id] = new FormControl(controlState, controlOptions);
|
controls[model.id] = new UntypedFormControl(controlState, controlOptions);
|
||||||
});
|
});
|
||||||
return new FormGroup(controls, options);
|
return new UntypedFormGroup(controls, options);
|
||||||
},
|
},
|
||||||
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
||||||
return {
|
return {
|
||||||
@@ -537,7 +537,7 @@ describe('EPersonFormComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call epersonRegistrationService.registerEmail', () => {
|
it('should call epersonRegistrationService.registerEmail', () => {
|
||||||
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail);
|
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith(ePersonEmail, null, 'forgot');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
|
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
import { FormGroup } from '@angular/forms';
|
import { UntypedFormGroup } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
DynamicCheckboxModel,
|
DynamicCheckboxModel,
|
||||||
DynamicFormControlModel,
|
DynamicFormControlModel,
|
||||||
@@ -36,6 +36,8 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
|
|||||||
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||||
import { Registration } from '../../../core/shared/registration.model';
|
import { Registration } from '../../../core/shared/registration.model';
|
||||||
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
|
import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service';
|
||||||
|
import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component';
|
||||||
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-eperson-form',
|
selector: 'ds-eperson-form',
|
||||||
@@ -107,7 +109,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* A FormGroup that combines all inputs
|
* A FormGroup that combines all inputs
|
||||||
*/
|
*/
|
||||||
formGroup: FormGroup;
|
formGroup: UntypedFormGroup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventEmitter that's fired whenever the form is being submitted
|
* An EventEmitter that's fired whenever the form is being submitted
|
||||||
@@ -164,6 +166,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
isImpersonated = false;
|
isImpersonated = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A boolean that indicate if to display EPersonForm's Rest password button
|
||||||
|
*/
|
||||||
|
displayResetPassword = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string that indicate the label of Submit button
|
||||||
|
*/
|
||||||
|
submitLabel = 'form.create';
|
||||||
/**
|
/**
|
||||||
* Subscription to email field value change
|
* Subscription to email field value change
|
||||||
*/
|
*/
|
||||||
@@ -182,11 +193,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
public requestService: RequestService,
|
public requestService: RequestService,
|
||||||
private epersonRegistrationService: EpersonRegistrationService,
|
private epersonRegistrationService: EpersonRegistrationService,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
) {
|
) {
|
||||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||||
this.epersonInitial = eperson;
|
this.epersonInitial = eperson;
|
||||||
if (hasValue(eperson)) {
|
if (hasValue(eperson)) {
|
||||||
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
|
this.isImpersonated = this.authService.isImpersonatingUser(eperson.id);
|
||||||
|
this.displayResetPassword = true;
|
||||||
|
this.submitLabel = 'form.submit';
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -200,14 +214,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
initialisePage() {
|
initialisePage() {
|
||||||
|
|
||||||
observableCombineLatest(
|
observableCombineLatest([
|
||||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||||
this.translateService.get(`${this.messagePrefix}.email`),
|
this.translateService.get(`${this.messagePrefix}.email`),
|
||||||
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
||||||
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
||||||
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
||||||
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
|
]).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
|
||||||
this.firstName = new DynamicInputModel({
|
this.firstName = new DynamicInputModel({
|
||||||
id: 'firstName',
|
id: 'firstName',
|
||||||
label: firstName,
|
label: firstName,
|
||||||
@@ -374,10 +388,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
getFirstCompletedRemoteData()
|
getFirstCompletedRemoteData()
|
||||||
).subscribe((rd: RemoteData<EPerson>) => {
|
).subscribe((rd: RemoteData<EPerson>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name }));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) }));
|
||||||
this.submitForm.emit(ePersonToCreate);
|
this.submitForm.emit(ePersonToCreate);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name }));
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) }));
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -413,10 +427,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
const response = this.epersonService.updateEPerson(editedEperson);
|
const response = this.epersonService.updateEPerson(editedEperson);
|
||||||
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
|
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name }));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) }));
|
||||||
this.submitForm.emit(editedEperson);
|
this.submitForm.emit(editedEperson);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name }));
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) }));
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -464,7 +478,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
if (hasValue(eperson.id)) {
|
if (hasValue(eperson.id)) {
|
||||||
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||||
if (restResponse.hasSucceeded) {
|
if (restResponse.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) }));
|
||||||
this.submitForm.emit();
|
this.submitForm.emit();
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
||||||
@@ -491,7 +505,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
resetPassword() {
|
resetPassword() {
|
||||||
if (hasValue(this.epersonInitial.email)) {
|
if (hasValue(this.epersonInitial.email)) {
|
||||||
this.epersonRegistrationService.registerEmail(this.epersonInitial.email).pipe(getFirstCompletedRemoteData())
|
this.epersonRegistrationService.registerEmail(this.epersonInitial.email, null, TYPE_REQUEST_FORGOT).pipe(getFirstCompletedRemoteData())
|
||||||
.subscribe((response: RemoteData<Registration>) => {
|
.subscribe((response: RemoteData<Registration>) => {
|
||||||
if (response.hasSucceeded) {
|
if (response.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'),
|
this.notificationsService.success(this.translateService.get('admin.access-control.epeople.actions.reset'),
|
||||||
@@ -542,7 +556,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((list: PaginatedList<EPerson>) => {
|
.subscribe((list: PaginatedList<EPerson>) => {
|
||||||
if (list.totalElements > 0) {
|
if (list.totalElements > 0) {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
|
||||||
name: ePerson.name,
|
name: this.dsoNameService.getName(ePerson),
|
||||||
email: ePerson.email
|
email: ePerson.email
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@@ -9,13 +9,24 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #editheader>
|
<ng-template #editheader>
|
||||||
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
|
<h2 class="border-bottom pb-2">
|
||||||
|
<span
|
||||||
|
*dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
|
||||||
|
id: 'edit-group-page',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.head.edit' | translate}}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
|
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
|
||||||
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
|
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
|
||||||
<ds-alert *ngIf="!(canEdit$ | async) && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
|
<ds-alert *ngIf="!(canEdit$ | async) && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
|
||||||
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: (getLinkedDSO(groupBeingEdited) | async)?.payload?.name, comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })">
|
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: dsoNameService.getName((getLinkedDSO(groupBeingEdited) | async)?.payload), comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })">
|
||||||
</ds-alert>
|
</ds-alert>
|
||||||
|
|
||||||
<ds-form [formId]="formId"
|
<ds-form [formId]="formId"
|
||||||
|
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
@@ -36,6 +36,8 @@ import { NotificationsServiceStub } from '../../../shared/testing/notifications-
|
|||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
import { ValidateGroupExists } from './validators/group-exists.validator';
|
import { ValidateGroupExists } from './validators/group-exists.validator';
|
||||||
import { NoContent } from '../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../core/shared/NoContent.model';
|
||||||
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock';
|
||||||
|
|
||||||
describe('GroupFormComponent', () => {
|
describe('GroupFormComponent', () => {
|
||||||
let component: GroupFormComponent;
|
let component: GroupFormComponent;
|
||||||
@@ -130,9 +132,9 @@ describe('GroupFormComponent', () => {
|
|||||||
const controlModel = model;
|
const controlModel = model;
|
||||||
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
||||||
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
||||||
controls[model.id] = new FormControl(controlState, controlOptions);
|
controls[model.id] = new UntypedFormControl(controlState, controlOptions);
|
||||||
});
|
});
|
||||||
return new FormGroup(controls, options);
|
return new UntypedFormGroup(controls, options);
|
||||||
},
|
},
|
||||||
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
||||||
return {
|
return {
|
||||||
@@ -188,7 +190,7 @@ describe('GroupFormComponent', () => {
|
|||||||
translateService = getMockTranslateService();
|
translateService = getMockTranslateService();
|
||||||
router = new RouterMock();
|
router = new RouterMock();
|
||||||
notificationService = new NotificationsServiceStub();
|
notificationService = new NotificationsServiceStub();
|
||||||
TestBed.configureTestingModule({
|
return TestBed.configureTestingModule({
|
||||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
loader: {
|
loader: {
|
||||||
@@ -198,7 +200,8 @@ describe('GroupFormComponent', () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
declarations: [GroupFormComponent],
|
declarations: [GroupFormComponent],
|
||||||
providers: [GroupFormComponent,
|
providers: [
|
||||||
|
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
|
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
|
||||||
@@ -240,8 +243,8 @@ describe('GroupFormComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit a new group using the correct values', waitForAsync(() => {
|
it('should emit a new group using the correct values', (async () => {
|
||||||
fixture.whenStable().then(() => {
|
await fixture.whenStable().then(() => {
|
||||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@@ -266,8 +269,45 @@ describe('GroupFormComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit the existing group using the correct new values', waitForAsync(() => {
|
it('should edit with name and description operations', () => {
|
||||||
fixture.whenStable().then(() => {
|
const operations = [{
|
||||||
|
op: 'add',
|
||||||
|
path: '/metadata/dc.description',
|
||||||
|
value: 'testDescription'
|
||||||
|
}, {
|
||||||
|
op: 'replace',
|
||||||
|
path: '/name',
|
||||||
|
value: 'newGroupName'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit with description operations', () => {
|
||||||
|
component.groupName.value = null;
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const operations = [{
|
||||||
|
op: 'add',
|
||||||
|
path: '/metadata/dc.description',
|
||||||
|
value: 'testDescription'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit with name operations', () => {
|
||||||
|
component.groupDescription.value = null;
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const operations = [{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/name',
|
||||||
|
value: 'newGroupName'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit the existing group using the correct new values', (async () => {
|
||||||
|
await fixture.whenStable().then(() => {
|
||||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core';
|
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core';
|
||||||
import { FormGroup } from '@angular/forms';
|
import { UntypedFormGroup } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +46,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
|
|||||||
import { NoContent } from '../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../core/shared/NoContent.model';
|
||||||
import { Operation } from 'fast-json-patch';
|
import { Operation } from 'fast-json-patch';
|
||||||
import { ValidateGroupExists } from './validators/group-exists.validator';
|
import { ValidateGroupExists } from './validators/group-exists.validator';
|
||||||
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -95,7 +96,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* A FormGroup that combines all inputs
|
* A FormGroup that combines all inputs
|
||||||
*/
|
*/
|
||||||
formGroup: FormGroup;
|
formGroup: UntypedFormGroup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventEmitter that's fired whenever the form is being submitted
|
* An EventEmitter that's fired whenever the form is being submitted
|
||||||
@@ -134,7 +135,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
groupNameValueChangeSubscribe: Subscription;
|
groupNameValueChangeSubscribe: Subscription;
|
||||||
|
|
||||||
|
|
||||||
constructor(public groupDataService: GroupDataService,
|
constructor(
|
||||||
|
public groupDataService: GroupDataService,
|
||||||
private ePersonDataService: EPersonDataService,
|
private ePersonDataService: EPersonDataService,
|
||||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||||
private formBuilderService: FormBuilderService,
|
private formBuilderService: FormBuilderService,
|
||||||
@@ -145,7 +147,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
private authorizationService: AuthorizationDataService,
|
private authorizationService: AuthorizationDataService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
public requestService: RequestService,
|
public requestService: RequestService,
|
||||||
protected changeDetectorRef: ChangeDetectorRef) {
|
protected changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -331,7 +335,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((list: PaginatedList<Group>) => {
|
.subscribe((list: PaginatedList<Group>) => {
|
||||||
if (list.totalElements > 0) {
|
if (list.totalElements > 0) {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', {
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', {
|
||||||
name: group.name
|
name: this.dsoNameService.getName(group),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -346,8 +350,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (hasValue(this.groupDescription.value)) {
|
if (hasValue(this.groupDescription.value)) {
|
||||||
operations = [...operations, {
|
operations = [...operations, {
|
||||||
op: 'replace',
|
op: 'add',
|
||||||
path: '/metadata/dc.description/0/value',
|
path: '/metadata/dc.description',
|
||||||
value: this.groupDescription.value
|
value: this.groupDescription.value
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -364,10 +368,10 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
getFirstCompletedRemoteData()
|
getFirstCompletedRemoteData()
|
||||||
).subscribe((rd: RemoteData<Group>) => {
|
).subscribe((rd: RemoteData<Group>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: rd.payload.name }));
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: this.dsoNameService.getName(rd.payload) }));
|
||||||
this.submitForm.emit(rd.payload);
|
this.submitForm.emit(rd.payload);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.edited.failure', { name: group.name }));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.edited.failure', { name: this.dsoNameService.getName(group) }));
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -427,11 +431,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
this.groupDataService.delete(group.id).pipe(getFirstCompletedRemoteData())
|
this.groupDataService.delete(group.id).pipe(getFirstCompletedRemoteData())
|
||||||
.subscribe((rd: RemoteData<NoContent>) => {
|
.subscribe((rd: RemoteData<NoContent>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name }));
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: this.dsoNameService.getName(group) }));
|
||||||
this.onCancel();
|
this.onCancel();
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(
|
this.notificationsService.error(
|
||||||
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }),
|
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: this.dsoNameService.getName(group) }),
|
||||||
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.content', { cause: rd.errorMessage }));
|
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.content', { cause: rd.errorMessage }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -1,9 +1,19 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
<h4 id="search" class="border-bottom pb-2">
|
||||||
|
<span
|
||||||
|
*dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
|
||||||
|
id: 'edit-group-add-epeople',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['top', 'right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.search.head' | translate}}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||||
@@ -47,26 +57,32 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
|
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
|
||||||
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||||
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
<td class="align-middle">
|
||||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||||
|
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
|
||||||
|
{{ dsoNameService.getName(ePerson.eperson) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
||||||
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button *ngIf="(ePerson.memberOfGroup)"
|
<button *ngIf="ePerson.memberOfGroup"
|
||||||
(click)="deleteMemberFromGroup(ePerson)"
|
(click)="deleteMemberFromGroup(ePerson)"
|
||||||
class="btn btn-outline-danger btn-sm"
|
[disabled]="actionConfig.remove.disabled"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}">
|
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
|
||||||
|
<i [ngClass]="actionConfig.remove.icon"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button *ngIf="!(ePerson.memberOfGroup)"
|
<button *ngIf="!ePerson.memberOfGroup"
|
||||||
(click)="addMemberToGroup(ePerson)"
|
(click)="addMemberToGroup(ePerson)"
|
||||||
class="btn btn-outline-primary btn-sm"
|
[disabled]="actionConfig.add.disabled"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.eperson.name} }}">
|
[ngClass]="['btn btn-sm', actionConfig.add.css]"
|
||||||
<i class="fas fa-plus fa-fw"></i>
|
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
|
||||||
|
<i [ngClass]="actionConfig.add.icon"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -105,18 +121,31 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
|
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
|
||||||
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||||
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
<td class="align-middle">
|
||||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||||
|
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
|
||||||
|
{{ dsoNameService.getName(ePerson.eperson) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
||||||
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button (click)="deleteMemberFromGroup(ePerson)"
|
<button *ngIf="ePerson.memberOfGroup"
|
||||||
class="btn btn-outline-danger btn-sm"
|
(click)="deleteMemberFromGroup(ePerson)"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}">
|
[disabled]="actionConfig.remove.disabled"
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
|
||||||
|
<i [ngClass]="actionConfig.remove.icon"></i>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="!ePerson.memberOfGroup"
|
||||||
|
(click)="addMemberToGroup(ePerson)"
|
||||||
|
[disabled]="actionConfig.add.disabled"
|
||||||
|
[ngClass]="['btn btn-sm', actionConfig.add.css]"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
|
||||||
|
<i [ngClass]="actionConfig.add.icon"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
@@ -28,6 +28,8 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio
|
|||||||
import { RouterMock } from '../../../../shared/mocks/router.mock';
|
import { RouterMock } from '../../../../shared/mocks/router.mock';
|
||||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
|
||||||
|
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
|
||||||
|
|
||||||
describe('MembersListComponent', () => {
|
describe('MembersListComponent', () => {
|
||||||
let component: MembersListComponent;
|
let component: MembersListComponent;
|
||||||
@@ -37,10 +39,10 @@ describe('MembersListComponent', () => {
|
|||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
let groupsDataServiceStub: any;
|
let groupsDataServiceStub: any;
|
||||||
let activeGroup;
|
let activeGroup;
|
||||||
let allEPersons;
|
let allEPersons: EPerson[];
|
||||||
let allGroups;
|
let allGroups: Group[];
|
||||||
let epersonMembers;
|
let epersonMembers: EPerson[];
|
||||||
let subgroupMembers;
|
let subgroupMembers: Group[];
|
||||||
let paginationService;
|
let paginationService;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -53,7 +55,7 @@ describe('MembersListComponent', () => {
|
|||||||
activeGroup: activeGroup,
|
activeGroup: activeGroup,
|
||||||
epersonMembers: epersonMembers,
|
epersonMembers: epersonMembers,
|
||||||
subgroupMembers: subgroupMembers,
|
subgroupMembers: subgroupMembers,
|
||||||
findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
|
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
|
||||||
},
|
},
|
||||||
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
@@ -118,7 +120,7 @@ describe('MembersListComponent', () => {
|
|||||||
translateService = getMockTranslateService();
|
translateService = getMockTranslateService();
|
||||||
|
|
||||||
paginationService = new PaginationServiceStub();
|
paginationService = new PaginationServiceStub();
|
||||||
TestBed.configureTestingModule({
|
return TestBed.configureTestingModule({
|
||||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
loader: {
|
loader: {
|
||||||
@@ -135,6 +137,7 @@ describe('MembersListComponent', () => {
|
|||||||
{ provide: FormBuilderService, useValue: builderService },
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
{ provide: Router, useValue: new RouterMock() },
|
{ provide: Router, useValue: new RouterMock() },
|
||||||
{ provide: PaginationService, useValue: paginationService },
|
{ provide: PaginationService, useValue: paginationService },
|
||||||
|
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -147,8 +150,10 @@ describe('MembersListComponent', () => {
|
|||||||
});
|
});
|
||||||
afterEach(fakeAsync(() => {
|
afterEach(fakeAsync(() => {
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
|
fixture.debugElement.nativeElement.remove();
|
||||||
flush();
|
flush();
|
||||||
component = null;
|
component = null;
|
||||||
|
fixture.debugElement.nativeElement.remove();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {
|
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {
|
||||||
@@ -167,12 +172,19 @@ describe('MembersListComponent', () => {
|
|||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
describe('when searching without query', () => {
|
describe('when searching without query', () => {
|
||||||
let epersonsFound;
|
let epersonsFound: DebugElement[];
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
|
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
|
||||||
|
return observableOf(activeGroup.epersons.includes(ePerson));
|
||||||
|
});
|
||||||
component.search({ scope: 'metadata', query: '' });
|
component.search({ scope: 'metadata', query: '' });
|
||||||
tick();
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
|
// Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything
|
||||||
|
// because they don't change the value of activeGroup.epersons)
|
||||||
|
jasmine.getEnv().allowRespy(true);
|
||||||
|
spyOn(component, 'isMemberOfGroup').and.callThrough();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should display all epersons', () => {
|
it('should display all epersons', () => {
|
||||||
@@ -181,62 +193,56 @@ describe('MembersListComponent', () => {
|
|||||||
|
|
||||||
describe('if eperson is already a eperson', () => {
|
describe('if eperson is already a eperson', () => {
|
||||||
it('should have delete button, else it should have add button', () => {
|
it('should have delete button, else it should have add button', () => {
|
||||||
activeGroup.epersons.map((eperson: EPerson) => {
|
const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id);
|
||||||
epersonsFound.map((foundEPersonRowElement) => {
|
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
|
||||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child'));
|
||||||
const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child'));
|
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
|
||||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
if (memberIds.includes(epersonId.nativeElement.textContent)) {
|
||||||
if (epersonId.nativeElement.textContent === eperson.id) {
|
expect(addButton).toBeNull();
|
||||||
expect(addButton).toBeUndefined();
|
expect(deleteButton).not.toBeNull();
|
||||||
expect(deleteButton).toBeDefined();
|
} else {
|
||||||
} else {
|
expect(deleteButton).toBeNull();
|
||||||
expect(deleteButton).toBeUndefined();
|
expect(addButton).not.toBeNull();
|
||||||
expect(addButton).toBeDefined();
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('if first add button is pressed', () => {
|
describe('if first add button is pressed', () => {
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
|
const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
|
||||||
addButton.nativeElement.click();
|
addButton.nativeElement.click();
|
||||||
tick();
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}));
|
}));
|
||||||
it('all groups in search member of selected group', () => {
|
it('then all the ePersons are member of the active group', () => {
|
||||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
expect(epersonsFound.length).toEqual(2);
|
expect(epersonsFound.length).toEqual(2);
|
||||||
epersonsFound.map((foundEPersonRowElement) => {
|
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
|
||||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
|
||||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
expect(addButton).toBeNull();
|
||||||
expect(addButton).toBeUndefined();
|
expect(deleteButton).not.toBeNull();
|
||||||
expect(deleteButton).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('if first delete button is pressed', () => {
|
describe('if first delete button is pressed', () => {
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
|
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
|
||||||
addButton.nativeElement.click();
|
deleteButton.nativeElement.click();
|
||||||
tick();
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}));
|
}));
|
||||||
it('first eperson in search delete button, because now member', () => {
|
it('then no ePerson is member of the active group', () => {
|
||||||
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
epersonsFound.map((foundEPersonRowElement) => {
|
expect(epersonsFound.length).toEqual(2);
|
||||||
if (foundEPersonRowElement.debugElement !== undefined) {
|
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
|
||||||
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
|
||||||
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
expect(deleteButton).toBeUndefined();
|
expect(deleteButton).toBeNull();
|
||||||
expect(addButton).toBeDefined();
|
expect(addButton).not.toBeNull();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
ObservedValueOf,
|
ObservedValueOf,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators';
|
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators';
|
||||||
import {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model';
|
import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../../core/data/remote-data';
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||||
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||||
@@ -19,12 +19,15 @@ import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
|||||||
import { Group } from '../../../../core/eperson/models/group.model';
|
import { Group } from '../../../../core/eperson/models/group.model';
|
||||||
import {
|
import {
|
||||||
getFirstSucceededRemoteData,
|
getFirstSucceededRemoteData,
|
||||||
getFirstCompletedRemoteData, getAllCompletedRemoteData, getRemoteDataPayload
|
getFirstCompletedRemoteData,
|
||||||
|
getAllCompletedRemoteData,
|
||||||
|
getRemoteDataPayload
|
||||||
} from '../../../../core/shared/operators';
|
} from '../../../../core/shared/operators';
|
||||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||||
import {EpersonDtoModel} from '../../../../core/eperson/models/eperson-dto.model';
|
import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
|
||||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||||
|
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys to keep track of specific subscriptions
|
* Keys to keep track of specific subscriptions
|
||||||
@@ -35,6 +38,35 @@ enum SubKey {
|
|||||||
SearchResultsDTO,
|
SearchResultsDTO,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout config of the buttons in the last column
|
||||||
|
*/
|
||||||
|
export interface EPersonActionConfig {
|
||||||
|
/**
|
||||||
|
* The css classes that should be added to the button
|
||||||
|
*/
|
||||||
|
css?: string;
|
||||||
|
/**
|
||||||
|
* Whether the button should be disabled
|
||||||
|
*/
|
||||||
|
disabled: boolean;
|
||||||
|
/**
|
||||||
|
* The Font Awesome icon that should be used
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link EPersonActionConfig} that should be used to display the button. The remove config will be used when the
|
||||||
|
* {@link EPerson} is already a member of the {@link Group} and the remove config will be used otherwise.
|
||||||
|
*
|
||||||
|
* *See {@link actionConfig} for an example*
|
||||||
|
*/
|
||||||
|
export interface EPersonListActionConfig {
|
||||||
|
add: EPersonActionConfig;
|
||||||
|
remove: EPersonActionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-members-list',
|
selector: 'ds-members-list',
|
||||||
templateUrl: './members-list.component.html'
|
templateUrl: './members-list.component.html'
|
||||||
@@ -47,6 +79,20 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
@Input()
|
@Input()
|
||||||
messagePrefix: string;
|
messagePrefix: string;
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
actionConfig: EPersonListActionConfig = {
|
||||||
|
add: {
|
||||||
|
css: 'btn-outline-primary',
|
||||||
|
disabled: false,
|
||||||
|
icon: 'fas fa-plus fa-fw',
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
css: 'btn-outline-danger',
|
||||||
|
disabled: false,
|
||||||
|
icon: 'fas fa-trash-alt fa-fw'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EPeople being displayed in search result, initially all members, after search result of search
|
* EPeople being displayed in search result, initially all members, after search result of search
|
||||||
*/
|
*/
|
||||||
@@ -91,21 +137,21 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
// current active group being edited
|
// current active group being edited
|
||||||
groupBeingEdited: Group;
|
groupBeingEdited: Group;
|
||||||
|
|
||||||
paginationSub: Subscription;
|
constructor(
|
||||||
|
protected groupDataService: GroupDataService,
|
||||||
|
public ePersonDataService: EPersonDataService,
|
||||||
constructor(private groupDataService: GroupDataService,
|
protected translateService: TranslateService,
|
||||||
public ePersonDataService: EPersonDataService,
|
protected notificationsService: NotificationsService,
|
||||||
private translateService: TranslateService,
|
protected formBuilder: UntypedFormBuilder,
|
||||||
private notificationsService: NotificationsService,
|
protected paginationService: PaginationService,
|
||||||
private formBuilder: FormBuilder,
|
protected router: Router,
|
||||||
private paginationService: PaginationService,
|
public dsoNameService: DSONameService,
|
||||||
private router: Router) {
|
) {
|
||||||
this.currentSearchQuery = '';
|
this.currentSearchQuery = '';
|
||||||
this.currentSearchScope = 'metadata';
|
this.currentSearchScope = 'metadata';
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit(): void {
|
||||||
this.searchForm = this.formBuilder.group(({
|
this.searchForm = this.formBuilder.group(({
|
||||||
scope: 'metadata',
|
scope: 'metadata',
|
||||||
query: '',
|
query: '',
|
||||||
@@ -124,7 +170,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
* @param page the number of the page to retrieve
|
* @param page the number of the page to retrieve
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private retrieveMembers(page: number) {
|
retrieveMembers(page: number): void {
|
||||||
this.unsubFrom(SubKey.MembersDTO);
|
this.unsubFrom(SubKey.MembersDTO);
|
||||||
this.subs.set(SubKey.MembersDTO,
|
this.subs.set(SubKey.MembersDTO,
|
||||||
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
|
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
|
||||||
@@ -135,36 +181,36 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
getAllCompletedRemoteData(),
|
getAllCompletedRemoteData(),
|
||||||
map((rd: RemoteData<any>) => {
|
map((rd: RemoteData<any>) => {
|
||||||
if (rd.hasFailed) {
|
if (rd.hasFailed) {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage}));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
|
||||||
} else {
|
} else {
|
||||||
return rd;
|
return rd;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
|
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
|
||||||
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
|
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
|
||||||
const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
|
const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
|
||||||
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
|
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
|
||||||
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
||||||
epersonDtoModel.eperson = member;
|
epersonDtoModel.eperson = member;
|
||||||
epersonDtoModel.memberOfGroup = isMember;
|
epersonDtoModel.memberOfGroup = isMember;
|
||||||
return epersonDtoModel;
|
return epersonDtoModel;
|
||||||
});
|
});
|
||||||
return dto$;
|
return dto$;
|
||||||
})]);
|
})]);
|
||||||
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
|
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
|
||||||
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
|
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
|
||||||
|
}));
|
||||||
|
}))
|
||||||
|
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
|
||||||
|
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
|
||||||
}));
|
}));
|
||||||
}))
|
|
||||||
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
|
|
||||||
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the given ePerson is a member of the group currently being edited
|
* Whether the given ePerson is a member of the group currently being edited
|
||||||
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
|
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
|
||||||
*/
|
*/
|
||||||
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
|
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
|
||||||
@@ -193,7 +239,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
* @param key The key of the subscription to unsubscribe from
|
* @param key The key of the subscription to unsubscribe from
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private unsubFrom(key: SubKey) {
|
protected unsubFrom(key: SubKey) {
|
||||||
if (this.subs.has(key)) {
|
if (this.subs.has(key)) {
|
||||||
this.subs.get(key).unsubscribe();
|
this.subs.get(key).unsubscribe();
|
||||||
this.subs.delete(key);
|
this.subs.delete(key);
|
||||||
@@ -205,10 +251,11 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
* @param ePerson EPerson we want to delete as member from group that is currently being edited
|
* @param ePerson EPerson we want to delete as member from group that is currently being edited
|
||||||
*/
|
*/
|
||||||
deleteMemberFromGroup(ePerson: EpersonDtoModel) {
|
deleteMemberFromGroup(ePerson: EpersonDtoModel) {
|
||||||
|
ePerson.memberOfGroup = false;
|
||||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
if (activeGroup != null) {
|
if (activeGroup != null) {
|
||||||
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);
|
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);
|
||||||
this.showNotifications('deleteMember', response, ePerson.eperson.name, activeGroup);
|
this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
}
|
}
|
||||||
@@ -224,7 +271,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
if (activeGroup != null) {
|
if (activeGroup != null) {
|
||||||
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson);
|
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson);
|
||||||
this.showNotifications('addMember', response, ePerson.eperson.name, activeGroup);
|
this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
}
|
}
|
||||||
@@ -267,7 +314,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
|
|||||||
getAllCompletedRemoteData(),
|
getAllCompletedRemoteData(),
|
||||||
map((rd: RemoteData<any>) => {
|
map((rd: RemoteData<any>) => {
|
||||||
if (rd.hasFailed) {
|
if (rd.hasFailed) {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage}));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
|
||||||
} else {
|
} else {
|
||||||
return rd;
|
return rd;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,16 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
<h4 id="search" class="border-bottom pb-2">
|
||||||
|
<span *dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
|
||||||
|
id: 'edit-group-add-subgroups',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['top', 'right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.search.head' | translate}}
|
||||||
|
</span>
|
||||||
|
|
||||||
</h4>
|
</h4>
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||||
@@ -44,24 +53,28 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
|
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
|
||||||
<td class="align-middle">{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
<td class="align-middle">
|
||||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
<a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
|
||||||
|
{{ dsoNameService.getName(group) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||||
(click)="deleteSubgroupFromGroup(group)"
|
(click)="deleteSubgroupFromGroup(group)"
|
||||||
class="btn btn-outline-danger btn-sm deleteButton"
|
class="btn btn-outline-danger btn-sm deleteButton"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</p>
|
<span *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</span>
|
||||||
|
|
||||||
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||||
(click)="addSubgroupToGroup(group)"
|
(click)="addSubgroupToGroup(group)"
|
||||||
class="btn btn-outline-primary btn-sm addButton"
|
class="btn btn-outline-primary btn-sm addButton"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: group.name} }}">
|
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
|
||||||
<i class="fas fa-plus fa-fw"></i>
|
<i class="fas fa-plus fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,14 +112,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
|
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
|
||||||
<td class="align-middle">{{group.id}}</td>
|
<td class="align-middle">{{group.id}}</td>
|
||||||
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
<td class="align-middle">
|
||||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
<a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
|
||||||
|
{{ dsoNameService.getName(group) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button (click)="deleteSubgroupFromGroup(group)"
|
<button (click)="deleteSubgroupFromGroup(group)"
|
||||||
class="btn btn-outline-danger btn-sm deleteButton"
|
class="btn btn-outline-danger btn-sm deleteButton"
|
||||||
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,14 +1,6 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
|
||||||
import {
|
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
||||||
ComponentFixture,
|
|
||||||
fakeAsync,
|
|
||||||
flush,
|
|
||||||
inject,
|
|
||||||
TestBed,
|
|
||||||
tick,
|
|
||||||
waitForAsync
|
|
||||||
} from '@angular/core/testing';
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { BrowserModule, By } from '@angular/platform-browser';
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@@ -37,6 +29,8 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
|
||||||
|
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock';
|
||||||
|
|
||||||
describe('SubgroupsListComponent', () => {
|
describe('SubgroupsListComponent', () => {
|
||||||
let component: SubgroupsListComponent;
|
let component: SubgroupsListComponent;
|
||||||
@@ -46,8 +40,8 @@ describe('SubgroupsListComponent', () => {
|
|||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
let groupsDataServiceStub: any;
|
let groupsDataServiceStub: any;
|
||||||
let activeGroup;
|
let activeGroup;
|
||||||
let subgroups;
|
let subgroups: Group[];
|
||||||
let allGroups;
|
let allGroups: Group[];
|
||||||
let routerStub;
|
let routerStub;
|
||||||
let paginationService;
|
let paginationService;
|
||||||
|
|
||||||
@@ -65,7 +59,7 @@ describe('SubgroupsListComponent', () => {
|
|||||||
getSubgroups(): Group {
|
getSubgroups(): Group {
|
||||||
return this.activeGroup;
|
return this.activeGroup;
|
||||||
},
|
},
|
||||||
findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
return this.subgroups$.pipe(
|
return this.subgroups$.pipe(
|
||||||
map((currentGroups: Group[]) => {
|
map((currentGroups: Group[]) => {
|
||||||
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));
|
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));
|
||||||
@@ -116,6 +110,7 @@ describe('SubgroupsListComponent', () => {
|
|||||||
],
|
],
|
||||||
declarations: [SubgroupsListComponent],
|
declarations: [SubgroupsListComponent],
|
||||||
providers: [SubgroupsListComponent,
|
providers: [SubgroupsListComponent,
|
||||||
|
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
{ provide: FormBuilderService, useValue: builderService },
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
@@ -133,6 +128,7 @@ describe('SubgroupsListComponent', () => {
|
|||||||
});
|
});
|
||||||
afterEach(fakeAsync(() => {
|
afterEach(fakeAsync(() => {
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
|
fixture.debugElement.nativeElement.remove();
|
||||||
flush();
|
flush();
|
||||||
component = null;
|
component = null;
|
||||||
}));
|
}));
|
||||||
@@ -152,7 +148,7 @@ describe('SubgroupsListComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('if first group delete button is pressed', () => {
|
describe('if first group delete button is pressed', () => {
|
||||||
let groupsFound;
|
let groupsFound: DebugElement[];
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
|
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
|
||||||
addButton.triggerEventHandler('click', {
|
addButton.triggerEventHandler('click', {
|
||||||
@@ -170,7 +166,7 @@ describe('SubgroupsListComponent', () => {
|
|||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
describe('when searching with empty query', () => {
|
describe('when searching with empty query', () => {
|
||||||
let groupsFound;
|
let groupsFound: DebugElement[];
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
component.search({ query: '' });
|
component.search({ query: '' });
|
||||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
@@ -181,9 +177,9 @@ describe('SubgroupsListComponent', () => {
|
|||||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
expect(groupsFound.length).toEqual(2);
|
expect(groupsFound.length).toEqual(2);
|
||||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
|
const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
|
||||||
allGroups.map((group: Group) => {
|
allGroups.map((group: Group) => {
|
||||||
expect(groupIdsFound.find((foundEl) => {
|
expect(groupIdsFound.find((foundEl: DebugElement) => {
|
||||||
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||||
})).toBeTruthy();
|
})).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -195,30 +191,30 @@ describe('SubgroupsListComponent', () => {
|
|||||||
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
|
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
|
||||||
if (getSubgroups !== undefined && getSubgroups.length > 0) {
|
if (getSubgroups !== undefined && getSubgroups.length > 0) {
|
||||||
groupsFound.map((foundGroupRowElement) => {
|
groupsFound.map((foundGroupRowElement: DebugElement) => {
|
||||||
if (foundGroupRowElement.debugElement !== undefined) {
|
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
|
||||||
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
|
||||||
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
expect(addButton).toBeUndefined();
|
expect(addButton).toBeNull();
|
||||||
expect(deleteButton).toBeDefined();
|
if (activeGroup.id === groupId.nativeElement.textContent) {
|
||||||
|
expect(deleteButton).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(deleteButton).not.toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
getSubgroups.map((group: Group) => {
|
const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id);
|
||||||
groupsFound.map((foundGroupRowElement) => {
|
groupsFound.map((foundGroupRowElement: DebugElement) => {
|
||||||
if (foundGroupRowElement.debugElement !== undefined) {
|
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
|
||||||
const groupId = foundGroupRowElement.debugElement.query(By.css('td:first-child'));
|
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
|
||||||
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
if (subgroupIds.includes(groupId.nativeElement.textContent)) {
|
||||||
if (groupId.nativeElement.textContent === group.id) {
|
expect(addButton).toBeNull();
|
||||||
expect(addButton).toBeUndefined();
|
expect(deleteButton).not.toBeNull();
|
||||||
expect(deleteButton).toBeDefined();
|
} else {
|
||||||
} else {
|
expect(deleteButton).toBeNull();
|
||||||
expect(deleteButton).toBeUndefined();
|
expect(addButton).not.toBeNull();
|
||||||
expect(addButton).toBeDefined();
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
|
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
|
||||||
@@ -18,6 +18,7 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
|
|||||||
import { NoContent } from '../../../../core/shared/NoContent.model';
|
import { NoContent } from '../../../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||||
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||||
|
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys to keep track of specific subscriptions
|
* Keys to keep track of specific subscriptions
|
||||||
@@ -86,9 +87,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
|||||||
constructor(public groupDataService: GroupDataService,
|
constructor(public groupDataService: GroupDataService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: UntypedFormBuilder,
|
||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
private router: Router) {
|
private router: Router,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
this.currentSearchQuery = '';
|
this.currentSearchQuery = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
|||||||
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
if (activeGroup != null) {
|
if (activeGroup != null) {
|
||||||
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
|
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
|
||||||
this.showNotifications('deleteSubgroup', response, subgroup.name, activeGroup);
|
this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
}
|
}
|
||||||
@@ -193,7 +196,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
|||||||
if (activeGroup != null) {
|
if (activeGroup != null) {
|
||||||
if (activeGroup.uuid !== subgroup.uuid) {
|
if (activeGroup.uuid !== subgroup.uuid) {
|
||||||
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
|
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
|
||||||
this.showNotifications('addSubgroup', response, subgroup.name, activeGroup);
|
this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
<button class="mr-auto btn btn-success"
|
<button class="mr-auto btn btn-success"
|
||||||
[routerLink]="['newGroup']">
|
[routerLink]="['newGroup']">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
<span class="d-none d-sm-inline">{{messagePrefix + 'button.add' | translate}}</span>
|
<span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="flex-grow-1 mr-3">
|
<div class="flex-grow-1 mr-3">
|
||||||
<div class="form-group input-group">
|
<div class="form-group input-group">
|
||||||
<input type="text" name="query" id="query" formControlName="query"
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
class="form-control" attr.aria-label="{{messagePrefix + 'search.placeholder' | translate}}"
|
class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
|
||||||
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
|
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button type="submit" class="search-button btn btn-primary">
|
<button type="submit" class="search-button btn btn-primary">
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
|
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
|
||||||
<td>{{groupDto.group.id}}</td>
|
<td>{{groupDto.group.id}}</td>
|
||||||
<td>{{groupDto.group.name}}</td>
|
<td>{{ dsoNameService.getName(groupDto.group) }}</td>
|
||||||
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
|
<td>{{ dsoNameService.getName((groupDto.group.object | async)?.payload) }}</td>
|
||||||
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<button *ngSwitchCase="true"
|
<button *ngSwitchCase="true"
|
||||||
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
|
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
|
||||||
class="btn btn-outline-primary btn-sm btn-edit"
|
class="btn btn-outline-primary btn-sm btn-edit"
|
||||||
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}"
|
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: dsoNameService.getName(groupDto.group) } }}"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
|
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
|
||||||
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
|
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm btn-delete"
|
||||||
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
|
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: dsoNameService.getName(groupDto.group) } }}">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -32,8 +32,10 @@ import { PaginationService } from '../../core/pagination/pagination.service';
|
|||||||
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
||||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||||
import { NoContent } from '../../core/shared/NoContent.model';
|
import { NoContent } from '../../core/shared/NoContent.model';
|
||||||
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { DSONameServiceMock, UNDEFINED_NAME } from '../../shared/mocks/dso-name.service.mock';
|
||||||
|
|
||||||
describe('GroupRegistryComponent', () => {
|
describe('GroupsRegistryComponent', () => {
|
||||||
let component: GroupsRegistryComponent;
|
let component: GroupsRegistryComponent;
|
||||||
let fixture: ComponentFixture<GroupsRegistryComponent>;
|
let fixture: ComponentFixture<GroupsRegistryComponent>;
|
||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
@@ -160,7 +162,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
||||||
setIsAuthorized(true, true);
|
setIsAuthorized(true, true);
|
||||||
paginationService = new PaginationServiceStub();
|
paginationService = new PaginationServiceStub();
|
||||||
TestBed.configureTestingModule({
|
return TestBed.configureTestingModule({
|
||||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
loader: {
|
loader: {
|
||||||
@@ -171,6 +173,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
],
|
],
|
||||||
declarations: [GroupsRegistryComponent],
|
declarations: [GroupsRegistryComponent],
|
||||||
providers: [GroupsRegistryComponent,
|
providers: [GroupsRegistryComponent,
|
||||||
|
{ provide: DSONameService, useValue: new DSONameServiceMock() },
|
||||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
|
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
|
||||||
@@ -208,7 +211,7 @@ describe('GroupRegistryComponent', () => {
|
|||||||
it('should display community/collection name if present', () => {
|
it('should display community/collection name if present', () => {
|
||||||
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
|
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
|
||||||
expect(collectionNamesFound.length).toEqual(2);
|
expect(collectionNamesFound.length).toEqual(2);
|
||||||
expect(collectionNamesFound[0].nativeElement.textContent).toEqual('');
|
expect(collectionNamesFound[0].nativeElement.textContent).toEqual(UNDEFINED_NAME);
|
||||||
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
|
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
|
|||||||
import { NoContent } from '../../core/shared/NoContent.model';
|
import { NoContent } from '../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||||
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-groups-registry',
|
selector: 'ds-groups-registry',
|
||||||
@@ -99,12 +100,14 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
|||||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: UntypedFormBuilder,
|
||||||
protected routeService: RouteService,
|
protected routeService: RouteService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private authorizationService: AuthorizationDataService,
|
private authorizationService: AuthorizationDataService,
|
||||||
private paginationService: PaginationService,
|
private paginationService: PaginationService,
|
||||||
public requestService: RequestService) {
|
public requestService: RequestService,
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
this.currentSearchQuery = '';
|
this.currentSearchQuery = '';
|
||||||
this.searchForm = this.formBuilder.group(({
|
this.searchForm = this.formBuilder.group(({
|
||||||
query: this.currentSearchQuery,
|
query: this.currentSearchQuery,
|
||||||
@@ -201,10 +204,10 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((rd: RemoteData<NoContent>) => {
|
.subscribe((rd: RemoteData<NoContent>) => {
|
||||||
if (rd.hasSucceeded) {
|
if (rd.hasSucceeded) {
|
||||||
this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id];
|
this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id];
|
||||||
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.group.name }));
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(group.group) }));
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(
|
this.notificationsService.error(
|
||||||
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.group.name }),
|
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: this.dsoNameService.getName(group.group) }),
|
||||||
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: rd.errorMessage }));
|
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: rd.errorMessage }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -97,9 +97,15 @@ export class BatchImportPageComponent {
|
|||||||
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
|
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const title = this.translate.get('process.new.notification.error.title');
|
if (rd.statusCode === 413) {
|
||||||
const content = this.translate.get('process.new.notification.error.content');
|
const title = this.translate.get('process.new.notification.error.title');
|
||||||
this.notificationsService.error(title, content);
|
const content = this.translate.get('process.new.notification.error.max-upload.content');
|
||||||
|
this.notificationsService.error(title, content);
|
||||||
|
} else {
|
||||||
|
const title = this.translate.get('process.new.notification.error.title');
|
||||||
|
const content = this.translate.get('process.new.notification.error.content');
|
||||||
|
this.notificationsService.error(title, content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -29,7 +29,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
|
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
|
||||||
<td>
|
<td>
|
||||||
<label>
|
<label class="mb-0">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
[checked]="isSelected(bitstreamFormat) | async"
|
[checked]="isSelected(bitstreamFormat) | async"
|
||||||
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
|
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
|
||||||
|
@@ -29,7 +29,7 @@
|
|||||||
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
|
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page"
|
||||||
[ngClass]="{'table-primary' : isActive(schema) | async}">
|
[ngClass]="{'table-primary' : isActive(schema) | async}">
|
||||||
<td>
|
<td>
|
||||||
<label>
|
<label class="mb-0">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
[checked]="isSelected(schema) | async"
|
[checked]="isSelected(schema) | async"
|
||||||
(change)="selectMetadataSchema(schema, $event)"
|
(change)="selectMetadataSchema(schema, $event)"
|
||||||
|
@@ -1,3 +1,7 @@
|
|||||||
.selectable-row:hover {
|
.selectable-row:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep #metadatadataschemagroup {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
@@ -5,7 +5,7 @@ import {
|
|||||||
DynamicFormLayout,
|
DynamicFormLayout,
|
||||||
DynamicInputModel
|
DynamicInputModel
|
||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
import { FormGroup } from '@angular/forms';
|
import { UntypedFormGroup } from '@angular/forms';
|
||||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
@@ -66,7 +66,7 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* A FormGroup that combines all inputs
|
* A FormGroup that combines all inputs
|
||||||
*/
|
*/
|
||||||
formGroup: FormGroup;
|
formGroup: UntypedFormGroup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventEmitter that's fired whenever the form is being submitted
|
* An EventEmitter that's fired whenever the form is being submitted
|
||||||
|
@@ -5,7 +5,7 @@ import {
|
|||||||
DynamicFormLayout,
|
DynamicFormLayout,
|
||||||
DynamicInputModel
|
DynamicInputModel
|
||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
import { FormGroup } from '@angular/forms';
|
import { UntypedFormGroup } from '@angular/forms';
|
||||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
@@ -82,7 +82,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* A FormGroup that combines all inputs
|
* A FormGroup that combines all inputs
|
||||||
*/
|
*/
|
||||||
formGroup: FormGroup;
|
formGroup: UntypedFormGroup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventEmitter that's fired whenever the form is being submitted
|
* An EventEmitter that's fired whenever the form is being submitted
|
||||||
|
@@ -34,14 +34,14 @@
|
|||||||
<tr *ngFor="let field of fields?.page"
|
<tr *ngFor="let field of fields?.page"
|
||||||
[ngClass]="{'table-primary' : isActive(field) | async}">
|
[ngClass]="{'table-primary' : isActive(field) | async}">
|
||||||
<td>
|
<td>
|
||||||
<label>
|
<label class="mb-0">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
[checked]="isSelected(field) | async"
|
[checked]="isSelected(field) | async"
|
||||||
(change)="selectMetadataField(field, $event)">
|
(change)="selectMetadataField(field, $event)">
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
|
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
|
||||||
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
|
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier" class="mb-0">.</label>{{field.qualifier}}</td>
|
||||||
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
|
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@@ -1,3 +1,8 @@
|
|||||||
.selectable-row:hover {
|
.selectable-row:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep #metadatadatafieldgroup {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
|
|||||||
component: BatchImportPageComponent,
|
component: BatchImportPageComponent,
|
||||||
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
|
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system-wide-alert',
|
||||||
|
resolve: { breadcrumb: I18nBreadcrumbResolver },
|
||||||
|
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
|
||||||
|
data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
|
||||||
|
},
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
@@ -19,7 +19,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-
|
|||||||
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
||||||
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
||||||
import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service';
|
import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service';
|
||||||
import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model';
|
import { AccessStatusObject } from '../../../../../shared/object-collection/shared/badges/access-status-badge/access-status.model';
|
||||||
import { AuthService } from '../../../../../core/auth/auth.service';
|
import { AuthService } from '../../../../../core/auth/auth.service';
|
||||||
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
||||||
import { FileService } from '../../../../../core/shared/file.service';
|
import { FileService } from '../../../../../core/shared/file.service';
|
||||||
|
@@ -13,6 +13,7 @@ import { BitstreamDataService } from '../../../../../core/data/bitstream-data.se
|
|||||||
import { GenericConstructor } from '../../../../../core/shared/generic-constructor';
|
import { GenericConstructor } from '../../../../../core/shared/generic-constructor';
|
||||||
import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive';
|
import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive';
|
||||||
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
import { ThemeService } from '../../../../../shared/theme-support/theme.service';
|
||||||
|
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
@listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch)
|
@listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch)
|
||||||
@Component({
|
@Component({
|
||||||
@@ -28,12 +29,14 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE
|
|||||||
@ViewChild('badges', { static: true }) badges: ElementRef;
|
@ViewChild('badges', { static: true }) badges: ElementRef;
|
||||||
@ViewChild('buttons', { static: true }) buttons: ElementRef;
|
@ViewChild('buttons', { static: true }) buttons: ElementRef;
|
||||||
|
|
||||||
constructor(protected truncatableService: TruncatableService,
|
constructor(
|
||||||
protected bitstreamDataService: BitstreamDataService,
|
public dsoNameService: DSONameService,
|
||||||
private themeService: ThemeService,
|
protected truncatableService: TruncatableService,
|
||||||
private componentFactoryResolver: ComponentFactoryResolver
|
protected bitstreamDataService: BitstreamDataService,
|
||||||
|
private themeService: ThemeService,
|
||||||
|
private componentFactoryResolver: ComponentFactoryResolver,
|
||||||
) {
|
) {
|
||||||
super(truncatableService, bitstreamDataService);
|
super(dsoNameService, truncatableService, bitstreamDataService);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -2,6 +2,5 @@
|
|||||||
[viewMode]="viewModes.ListElement"
|
[viewMode]="viewModes.ListElement"
|
||||||
[index]="index"
|
[index]="index"
|
||||||
[linkType]="linkType"
|
[linkType]="linkType"
|
||||||
[listID]="listID"
|
[listID]="listID"></ds-listable-object-component-loader>
|
||||||
[hideBadges]="true"></ds-listable-object-component-loader>
|
|
||||||
<ds-item-admin-search-result-actions-element [item]="dso" [small]="false"></ds-item-admin-search-result-actions-element>
|
<ds-item-admin-search-result-actions-element [item]="dso" [small]="false"></ds-item-admin-search-result-actions-element>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
|
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
|
||||||
[ngClass]="{ disabled: !hasLink }"
|
[ngClass]="{ disabled: isDisabled }"
|
||||||
[attr.aria-disabled]="!hasLink"
|
[attr.aria-disabled]="isDisabled"
|
||||||
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
||||||
[title]="('menu.section.icon.' + section.id) | translate"
|
[title]="('menu.section.icon.' + section.id) | translate"
|
||||||
[routerLink]="itemModel.link"
|
[routerLink]="itemModel.link"
|
||||||
|
@@ -17,38 +17,86 @@ describe('AdminSidebarSectionComponent', () => {
|
|||||||
const menuService = new MenuServiceStub();
|
const menuService = new MenuServiceStub();
|
||||||
const iconString = 'test';
|
const iconString = 'test';
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
describe('when not disabled', () => {
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
|
||||||
declarations: [AdminSidebarSectionComponent, TestComponent],
|
|
||||||
providers: [
|
|
||||||
{ provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } },
|
|
||||||
{ provide: MenuService, useValue: menuService },
|
|
||||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
|
||||||
]
|
|
||||||
}).overrideComponent(AdminSidebarSectionComponent, {
|
|
||||||
set: {
|
|
||||||
entryComponents: [TestComponent]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
|
TestBed.configureTestingModule({
|
||||||
component = fixture.componentInstance;
|
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
||||||
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
declarations: [AdminSidebarSectionComponent, TestComponent],
|
||||||
fixture.detectChanges();
|
providers: [
|
||||||
|
{provide: 'sectionDataProvider', useValue: {model: {link: 'google.com'}, icon: iconString}},
|
||||||
|
{provide: MenuService, useValue: menuService},
|
||||||
|
{provide: CSSVariableService, useClass: CSSVariableServiceStub},
|
||||||
|
]
|
||||||
|
}).overrideComponent(AdminSidebarSectionComponent, {
|
||||||
|
set: {
|
||||||
|
entryComponents: [TestComponent]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the right icon', () => {
|
||||||
|
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
||||||
|
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
||||||
|
});
|
||||||
|
it('should not contain the disabled class', () => {
|
||||||
|
const disabled = fixture.debugElement.query(By.css('.disabled'));
|
||||||
|
expect(disabled).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
describe('when disabled', () => {
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
||||||
|
declarations: [AdminSidebarSectionComponent, TestComponent],
|
||||||
|
providers: [
|
||||||
|
{provide: 'sectionDataProvider', useValue: {model: {link: 'google.com', disabled: true}, icon: iconString}},
|
||||||
|
{provide: MenuService, useValue: menuService},
|
||||||
|
{provide: CSSVariableService, useClass: CSSVariableServiceStub},
|
||||||
|
]
|
||||||
|
}).overrideComponent(AdminSidebarSectionComponent, {
|
||||||
|
set: {
|
||||||
|
entryComponents: [TestComponent]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the right icon', () => {
|
||||||
|
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
||||||
|
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
||||||
|
});
|
||||||
|
it('should contain the disabled class', () => {
|
||||||
|
const disabled = fixture.debugElement.query(By.css('.disabled'));
|
||||||
|
expect(disabled).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set the right icon', () => {
|
|
||||||
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
|
||||||
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// declare a test component
|
// declare a test component
|
||||||
|
@@ -5,7 +5,7 @@ import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorat
|
|||||||
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
||||||
import { MenuSection } from '../../../shared/menu/menu-section.model';
|
import { MenuSection } from '../../../shared/menu/menu-section.model';
|
||||||
import { MenuID } from '../../../shared/menu/menu-id.model';
|
import { MenuID } from '../../../shared/menu/menu-id.model';
|
||||||
import { isNotEmpty } from '../../../shared/empty.util';
|
import { isEmpty } from '../../../shared/empty.util';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +26,12 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
|
|||||||
*/
|
*/
|
||||||
menuID: MenuID = MenuID.ADMIN;
|
menuID: MenuID = MenuID.ADMIN;
|
||||||
itemModel;
|
itemModel;
|
||||||
hasLink: boolean;
|
|
||||||
|
/**
|
||||||
|
* Boolean to indicate whether this section is disabled
|
||||||
|
*/
|
||||||
|
isDisabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('sectionDataProvider') menuSection: MenuSection,
|
@Inject('sectionDataProvider') menuSection: MenuSection,
|
||||||
protected menuService: MenuService,
|
protected menuService: MenuService,
|
||||||
@@ -38,13 +43,13 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.hasLink = isNotEmpty(this.itemModel?.link);
|
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
|
||||||
super.ngOnInit();
|
super.ngOnInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(event: any): void {
|
navigate(event: any): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.hasLink) {
|
if (!this.isDisabled) {
|
||||||
this.router.navigate(this.itemModel.link);
|
this.router.navigate(this.itemModel.link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('focusin')
|
@HostListener('focusin')
|
||||||
|
@@ -7,6 +7,7 @@
|
|||||||
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
[attr.aria-labelledby]="'sidebarName-' + section.id"
|
||||||
[attr.aria-expanded]="expanded | async"
|
[attr.aria-expanded]="expanded | async"
|
||||||
[title]="('menu.section.icon.' + section.id) | translate"
|
[title]="('menu.section.icon.' + section.id) | translate"
|
||||||
|
[class.disabled]="section.model?.disabled"
|
||||||
(click)="toggleSection($event)"
|
(click)="toggleSection($event)"
|
||||||
(keyup.space)="toggleSection($event)"
|
(keyup.space)="toggleSection($event)"
|
||||||
(keyup.enter)="toggleSection($event)"
|
(keyup.enter)="toggleSection($event)"
|
||||||
|
@@ -23,7 +23,7 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
imports: [NoopAnimationsModule, TranslateModule.forRoot()],
|
imports: [NoopAnimationsModule, TranslateModule.forRoot()],
|
||||||
declarations: [ExpandableAdminSidebarSectionComponent, TestComponent],
|
declarations: [ExpandableAdminSidebarSectionComponent, TestComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'sectionDataProvider', useValue: { icon: iconString } },
|
{ provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } },
|
||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||||
{ provide: Router, useValue: new RouterStub() },
|
{ provide: Router, useValue: new RouterStub() },
|
||||||
|
@@ -1 +1 @@
|
|||||||
<ds-configuration-search-page configuration="workflowAdmin" [context]="context"></ds-configuration-search-page>
|
<ds-configuration-search-page configuration="supervision" [context]="context"></ds-configuration-search-page>
|
||||||
|
@@ -4,24 +4,32 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
|
|||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { URLCombiner } from '../../../core/url-combiner/url-combiner';
|
import { URLCombiner } from '../../../../../core/url-combiner/url-combiner';
|
||||||
import { WorkflowItemAdminWorkflowActionsComponent } from './workflow-item-admin-workflow-actions.component';
|
import { WorkflowItemAdminWorkflowActionsComponent } from './workflow-item-admin-workflow-actions.component';
|
||||||
import { WorkflowItem } from '../../../core/submission/models/workflowitem.model';
|
import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model';
|
||||||
import {
|
import {
|
||||||
getWorkflowItemDeleteRoute,
|
getWorkflowItemDeleteRoute,
|
||||||
getWorkflowItemSendBackRoute
|
getWorkflowItemSendBackRoute
|
||||||
} from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
|
} from '../../../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { Item } from '../../../../../core/shared/item.model';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { RequestEntryState } from '../../../../../core/data/request-entry-state.model';
|
||||||
|
|
||||||
describe('WorkflowItemAdminWorkflowActionsComponent', () => {
|
describe('WorkflowItemAdminWorkflowActionsComponent', () => {
|
||||||
let component: WorkflowItemAdminWorkflowActionsComponent;
|
let component: WorkflowItemAdminWorkflowActionsComponent;
|
||||||
let fixture: ComponentFixture<WorkflowItemAdminWorkflowActionsComponent>;
|
let fixture: ComponentFixture<WorkflowItemAdminWorkflowActionsComponent>;
|
||||||
let id;
|
let id;
|
||||||
let wfi;
|
let wfi;
|
||||||
|
let item = new Item();
|
||||||
|
item.uuid = 'itemUUID1111';
|
||||||
|
const rd = new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, item, 200);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
id = '780b2588-bda5-4112-a1cd-0b15000a5339';
|
id = '780b2588-bda5-4112-a1cd-0b15000a5339';
|
||||||
wfi = new WorkflowItem();
|
wfi = new WorkflowItem();
|
||||||
wfi.id = id;
|
wfi.id = id;
|
||||||
|
wfi.item = of(rd);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -59,4 +67,5 @@ describe('WorkflowItemAdminWorkflowActionsComponent', () => {
|
|||||||
const link = a.nativeElement.href;
|
const link = a.nativeElement.href;
|
||||||
expect(link).toContain(new URLCombiner(getWorkflowItemSendBackRoute(wfi.id)).toString());
|
expect(link).toContain(new URLCombiner(getWorkflowItemSendBackRoute(wfi.id)).toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
@@ -1,9 +1,10 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input } from '@angular/core';
|
||||||
import { WorkflowItem } from '../../../core/submission/models/workflowitem.model';
|
|
||||||
|
import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model';
|
||||||
import {
|
import {
|
||||||
getWorkflowItemSendBackRoute,
|
getWorkflowItemDeleteRoute,
|
||||||
getWorkflowItemDeleteRoute
|
getWorkflowItemSendBackRoute
|
||||||
} from '../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
|
} from '../../../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-workflow-item-admin-workflow-actions-element',
|
selector: 'ds-workflow-item-admin-workflow-actions-element',
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
templateUrl: './workflow-item-admin-workflow-actions.component.html'
|
templateUrl: './workflow-item-admin-workflow-actions.component.html'
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* The component for displaying the actions for a list element for an item on the admin workflow search page
|
* The component for displaying the actions for a list element for a workflow-item on the admin workflow search page
|
||||||
*/
|
*/
|
||||||
export class WorkflowItemAdminWorkflowActionsComponent {
|
export class WorkflowItemAdminWorkflowActionsComponent {
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export class WorkflowItemAdminWorkflowActionsComponent {
|
|||||||
@Input() public wfi: WorkflowItem;
|
@Input() public wfi: WorkflowItem;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not to use small buttons
|
* Whether to use small buttons or not
|
||||||
*/
|
*/
|
||||||
@Input() public small: boolean;
|
@Input() public small: boolean;
|
||||||
|
|
||||||
@@ -29,7 +30,6 @@ export class WorkflowItemAdminWorkflowActionsComponent {
|
|||||||
* Returns the path to the delete page of this workflow item
|
* Returns the path to the delete page of this workflow item
|
||||||
*/
|
*/
|
||||||
getDeleteRoute(): string {
|
getDeleteRoute(): string {
|
||||||
|
|
||||||
return getWorkflowItemDeleteRoute(this.wfi.id);
|
return getWorkflowItemDeleteRoute(this.wfi.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,4 +39,5 @@ export class WorkflowItemAdminWorkflowActionsComponent {
|
|||||||
getSendBackRoute(): string {
|
getSendBackRoute(): string {
|
||||||
return getWorkflowItemSendBackRoute(this.wfi.id);
|
return getWorkflowItemSendBackRoute(this.wfi.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@@ -0,0 +1,44 @@
|
|||||||
|
<div>
|
||||||
|
<div class="modal-header">{{'supervision-group-selector.header' | translate}}
|
||||||
|
<button type="button" class="close" (click)="close()" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="control-group col-sm-12">
|
||||||
|
<label for="supervisionOrder">{{'supervision-group-selector.select.type-of-order.label' | translate}}</label>
|
||||||
|
<select name="supervisionOrder" id="supervisionOrder" class="form-control"
|
||||||
|
[(ngModel)]="selectedOrderType"
|
||||||
|
attr.aria-label="{{'supervision-group-selector.select.type-of-order.label' | translate}}">
|
||||||
|
<option value="EDITOR">{{'supervision-group-selector.select.type-of-order.option.editor' | translate}}</option>
|
||||||
|
<option value="OBSERVER">{{'supervision-group-selector.select.type-of-order.option.observer' | translate}}</option>
|
||||||
|
</select>
|
||||||
|
<ds-error *ngIf="isSubmitted && (!selectedOrderType || selectedOrderType === '')"
|
||||||
|
message="{{'supervision-group-selector.select.type-of-order.error' | translate}}"></ds-error>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="control-group col-sm-12">
|
||||||
|
<label for="supervisionGroup">{{'supervision-group-selector.select.group.label' | translate}}</label>
|
||||||
|
<ng-container class="mb-3">
|
||||||
|
<input id="supervisionGroup" class="form-control" type="text" [value]="selectedGroup ? dsoNameService.getName(selectedGroup) : ''" disabled>
|
||||||
|
<ds-error *ngIf="isSubmitted && !selectedGroup" message="{{'supervision-group-selector.select.group.error' | translate}}"></ds-error>
|
||||||
|
</ng-container>
|
||||||
|
<ds-eperson-group-list [isListOfEPerson]="false"
|
||||||
|
(select)="updateGroupObjectSelected($event)"></ds-eperson-group-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="d-flex flex-row-reverse m-2"> -->
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary"
|
||||||
|
(click)="close()">
|
||||||
|
<i class="fas fa-times"></i> {{"supervision-group-selector.button.cancel" | translate}}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary save"
|
||||||
|
(click)="save()">
|
||||||
|
<i class="fas fa-save"></i> {{"supervision-group-selector.button.save" | translate}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,70 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { SupervisionOrderGroupSelectorComponent } from './supervision-order-group-selector.component';
|
||||||
|
import { SupervisionOrderDataService } from '../../../../../../core/supervision-order/supervision-order-data.service';
|
||||||
|
import { NotificationsService } from '../../../../../../shared/notifications/notifications.service';
|
||||||
|
import { Group } from '../../../../../../core/eperson/models/group.model';
|
||||||
|
import { SupervisionOrder } from '../../../../../../core/supervision-order/models/supervision-order.model';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
describe('SupervisionOrderGroupSelectorComponent', () => {
|
||||||
|
let component: SupervisionOrderGroupSelectorComponent;
|
||||||
|
let fixture: ComponentFixture<SupervisionOrderGroupSelectorComponent>;
|
||||||
|
let debugElement: DebugElement;
|
||||||
|
|
||||||
|
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
|
||||||
|
|
||||||
|
const supervisionOrderDataService: any = jasmine.createSpyObj('supervisionOrderDataService', {
|
||||||
|
create: of(new SupervisionOrder())
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedOrderType = 'NONE';
|
||||||
|
const itemUUID = 'itemUUID1234';
|
||||||
|
|
||||||
|
const selectedGroup = new Group();
|
||||||
|
selectedGroup.uuid = 'GroupUUID1234';
|
||||||
|
|
||||||
|
const supervisionDataObject = new SupervisionOrder();
|
||||||
|
supervisionDataObject.ordertype = selectedOrderType;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [SupervisionOrderGroupSelectorComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: NgbActiveModal, useValue: modalStub },
|
||||||
|
{ provide: SupervisionOrderDataService, useValue: supervisionOrderDataService },
|
||||||
|
{ provide: NotificationsService, useValue: {} },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
fixture = TestBed.createComponent(SupervisionOrderGroupSelectorComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
component.itemUUID = itemUUID;
|
||||||
|
component.selectedGroup = selectedGroup;
|
||||||
|
component.selectedOrderType = selectedOrderType;
|
||||||
|
debugElement = fixture.debugElement;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create component', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call create for supervision order', () => {
|
||||||
|
component.save();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(supervisionOrderDataService.create).toHaveBeenCalledWith(supervisionDataObject, itemUUID, selectedGroup.uuid, selectedOrderType);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,97 @@
|
|||||||
|
import { Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { getFirstCompletedRemoteData } from 'src/app/core/shared/operators';
|
||||||
|
import { NotificationsService } from 'src/app/shared/notifications/notifications.service';
|
||||||
|
import { DSONameService } from '../../../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { Group } from '../../../../../../core/eperson/models/group.model';
|
||||||
|
import { SupervisionOrder } from '../../../../../../core/supervision-order/models/supervision-order.model';
|
||||||
|
import { SupervisionOrderDataService } from '../../../../../../core/supervision-order/supervision-order-data.service';
|
||||||
|
import { RemoteData } from '../../../../../../core/data/remote-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to wrap a dropdown - for type of order -
|
||||||
|
* and a list of groups
|
||||||
|
* inside a modal
|
||||||
|
* Used to create a new supervision order
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-supervision-group-selector',
|
||||||
|
styleUrls: ['./supervision-order-group-selector.component.scss'],
|
||||||
|
templateUrl: './supervision-order-group-selector.component.html',
|
||||||
|
})
|
||||||
|
export class SupervisionOrderGroupSelectorComponent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item to perform the actions on
|
||||||
|
*/
|
||||||
|
itemUUID: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected supervision order type
|
||||||
|
*/
|
||||||
|
selectedOrderType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* selected group for supervision
|
||||||
|
*/
|
||||||
|
selectedGroup: Group;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boolean flag for the validations
|
||||||
|
*/
|
||||||
|
isSubmitted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitted when a new SupervisionOrder has been created
|
||||||
|
*/
|
||||||
|
@Output() create: EventEmitter<SupervisionOrder> = new EventEmitter<SupervisionOrder>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
private activeModal: NgbActiveModal,
|
||||||
|
private supervisionOrderDataService: SupervisionOrderDataService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.activeModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign the value of group on select
|
||||||
|
*/
|
||||||
|
updateGroupObjectSelected(object) {
|
||||||
|
this.selectedGroup = object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the supervision order
|
||||||
|
*/
|
||||||
|
save() {
|
||||||
|
this.isSubmitted = true;
|
||||||
|
if (this.selectedOrderType && this.selectedGroup) {
|
||||||
|
let supervisionDataObject = new SupervisionOrder();
|
||||||
|
supervisionDataObject.ordertype = this.selectedOrderType;
|
||||||
|
this.supervisionOrderDataService.create(supervisionDataObject, this.itemUUID, this.selectedGroup.uuid, this.selectedOrderType).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
).subscribe((rd: RemoteData<SupervisionOrder>) => {
|
||||||
|
if (rd.state === 'Success') {
|
||||||
|
this.notificationsService.success(this.translateService.get('supervision-group-selector.notification.create.success.title', { name: this.dsoNameService.getName(this.selectedGroup) }));
|
||||||
|
this.create.emit(rd.payload);
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(
|
||||||
|
this.translateService.get('supervision-group-selector.notification.create.failure.title'),
|
||||||
|
rd.statusCode === 422 ? this.translateService.get('supervision-group-selector.notification.create.already-existing') : rd.errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,15 @@
|
|||||||
|
<ng-container *ngVar="(supervisionOrderEntries$ | async) as supervisionOrders">
|
||||||
|
<div class="item-list-supervision" *ngIf="supervisionOrders?.length > 0">
|
||||||
|
<div>
|
||||||
|
<span>{{'workflow-item.search.result.list.element.supervised-by' | translate}} </span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a class="badge badge-primary mr-1 mb-1 text-capitalize mw-100 text-truncate" *ngFor="let supervisionOrder of supervisionOrders" data-test="soBadge"
|
||||||
|
[ngbTooltip]="'workflow-item.search.result.list.element.supervised.remove-tooltip' | translate"
|
||||||
|
(click)="$event.preventDefault(); $event.stopImmediatePropagation(); deleteSupervisionOrder(supervisionOrder)" aria-label="Close">
|
||||||
|
{{ dsoNameService.getName(supervisionOrder.group) }}
|
||||||
|
<span aria-hidden="true"> ×</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
@@ -0,0 +1,3 @@
|
|||||||
|
.badge {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { SupervisionOrderStatusComponent } from './supervision-order-status.component';
|
||||||
|
import { VarDirective } from '../../../../../../shared/utils/var.directive';
|
||||||
|
import { TranslateLoaderMock } from '../../../../../../shared/mocks/translate-loader.mock';
|
||||||
|
import { supervisionOrderListMock } from '../../../../../../shared/testing/supervision-order.mock';
|
||||||
|
|
||||||
|
describe('SupervisionOrderStatusComponent', () => {
|
||||||
|
let component: SupervisionOrderStatusComponent;
|
||||||
|
let fixture: ComponentFixture<SupervisionOrderStatusComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgbTooltipModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: TranslateLoaderMock
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
declarations: [ SupervisionOrderStatusComponent, VarDirective ],
|
||||||
|
schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(SupervisionOrderStatusComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.supervisionOrderList = supervisionOrderListMock;
|
||||||
|
component.ngOnChanges( {
|
||||||
|
supervisionOrderList: new SimpleChange(null, supervisionOrderListMock, true)
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render badges properly', () => {
|
||||||
|
const badges = fixture.debugElement.queryAll(By.css('[data-test="soBadge"]'));
|
||||||
|
expect(badges.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit delete event on click', () => {
|
||||||
|
spyOn(component.delete, 'emit');
|
||||||
|
const badges = fixture.debugElement.queryAll(By.css('[data-test="soBadge"]'));
|
||||||
|
badges[0].nativeElement.click();
|
||||||
|
expect(component.delete.emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,88 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
|
||||||
|
|
||||||
|
import { BehaviorSubject, from, Observable } from 'rxjs';
|
||||||
|
import { map, mergeMap, reduce } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { SupervisionOrder } from '../../../../../../core/supervision-order/models/supervision-order.model';
|
||||||
|
import { Group } from '../../../../../../core/eperson/models/group.model';
|
||||||
|
import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators';
|
||||||
|
import { isNotEmpty } from '../../../../../../shared/empty.util';
|
||||||
|
import { RemoteData } from '../../../../../../core/data/remote-data';
|
||||||
|
import { DSONameService } from '../../../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
|
export interface SupervisionOrderListEntry {
|
||||||
|
supervisionOrder: SupervisionOrder;
|
||||||
|
group: Group
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-supervision-order-status',
|
||||||
|
templateUrl: './supervision-order-status.component.html',
|
||||||
|
styleUrls: ['./supervision-order-status.component.scss']
|
||||||
|
})
|
||||||
|
export class SupervisionOrderStatusComponent implements OnChanges {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of supervision order object to show
|
||||||
|
*/
|
||||||
|
@Input() supervisionOrderList: SupervisionOrder[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of the supervision orders combined with the group
|
||||||
|
*/
|
||||||
|
supervisionOrderEntries$: BehaviorSubject<SupervisionOrderListEntry[]> = new BehaviorSubject([]);
|
||||||
|
|
||||||
|
@Output() delete: EventEmitter<SupervisionOrderListEntry> = new EventEmitter<SupervisionOrderListEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dsoNameService: DSONameService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes && changes.supervisionOrderList) {
|
||||||
|
this.getSupervisionOrderEntries(changes.supervisionOrderList.currentValue)
|
||||||
|
.subscribe((supervisionOrderEntries: SupervisionOrderListEntry[]) => {
|
||||||
|
this.supervisionOrderEntries$.next(supervisionOrderEntries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a list of SupervisionOrderListEntry by the given SupervisionOrder list
|
||||||
|
*
|
||||||
|
* @param supervisionOrderList
|
||||||
|
*/
|
||||||
|
private getSupervisionOrderEntries(supervisionOrderList: SupervisionOrder[]): Observable<SupervisionOrderListEntry[]> {
|
||||||
|
return from(supervisionOrderList).pipe(
|
||||||
|
mergeMap((so: SupervisionOrder) => so.group.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
map((sogRD: RemoteData<Group>) => {
|
||||||
|
if (sogRD.hasSucceeded) {
|
||||||
|
const entry: SupervisionOrderListEntry = {
|
||||||
|
supervisionOrder: so,
|
||||||
|
group: sogRD.payload
|
||||||
|
};
|
||||||
|
return entry;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
reduce((acc: SupervisionOrderListEntry[], value: any) => {
|
||||||
|
if (isNotEmpty(value)) {
|
||||||
|
return [...acc, value];
|
||||||
|
} else {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a delete event with the given SupervisionOrderListEntry.
|
||||||
|
*/
|
||||||
|
deleteSupervisionOrder(supervisionOrder: SupervisionOrderListEntry) {
|
||||||
|
this.delete.emit(supervisionOrder);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="my-1">
|
||||||
|
<ds-supervision-order-status [supervisionOrderList]="supervisionOrderList"
|
||||||
|
(delete)="deleteSupervisionOrder($event)"></ds-supervision-order-status>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-children-mr">
|
||||||
|
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 delete-link" [routerLink]="[getDeleteRoute()]" [title]="'admin.workflow.item.delete' | translate">
|
||||||
|
<i class="fa fa-trash"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.workflow.item.delete" | translate}}</span>
|
||||||
|
</a>
|
||||||
|
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 policies-link" [routerLink]="resourcePoliciesPageRoute" [title]="'admin.workflow.item.policies' | translate">
|
||||||
|
<i class="fas fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{'admin.workflow.item.policies' | translate}}</span>
|
||||||
|
</a>
|
||||||
|
<a [ngClass]="{'btn-sm': small}" class="btn btn-light my-1 supervision-group-selector" [title]="'admin.workflow.item.supervision' | translate" (click)="openSupervisionModal()">
|
||||||
|
<i class="fas fa-users-cog"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{'admin.workflow.item.supervision' | translate}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
@@ -0,0 +1,156 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { URLCombiner } from '../../../../../core/url-combiner/url-combiner';
|
||||||
|
import { WorkspaceItemAdminWorkflowActionsComponent } from './workspace-item-admin-workflow-actions.component';
|
||||||
|
import { WorkspaceItem } from '../../../../../core/submission/models/workspaceitem.model';
|
||||||
|
import {
|
||||||
|
getWorkspaceItemDeleteRoute,
|
||||||
|
} from '../../../../../workflowitems-edit-page/workflowitems-edit-page-routing-paths';
|
||||||
|
import { Item } from '../../../../../core/shared/item.model';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { RequestEntryState } from '../../../../../core/data/request-entry-state.model';
|
||||||
|
import { NotificationsServiceStub } from '../../../../../shared/testing/notifications-service.stub';
|
||||||
|
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||||
|
import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
|
||||||
|
import { SupervisionOrderDataService } from '../../../../../core/supervision-order/supervision-order-data.service';
|
||||||
|
import { ConfirmationModalComponent } from '../../../../../shared/confirmation-modal/confirmation-modal.component';
|
||||||
|
import { supervisionOrderEntryMock } from '../../../../../shared/testing/supervision-order.mock';
|
||||||
|
import {
|
||||||
|
SupervisionOrderGroupSelectorComponent
|
||||||
|
} from './supervision-order-group-selector/supervision-order-group-selector.component';
|
||||||
|
|
||||||
|
describe('WorkspaceItemAdminWorkflowActionsComponent', () => {
|
||||||
|
let component: WorkspaceItemAdminWorkflowActionsComponent;
|
||||||
|
let fixture: ComponentFixture<WorkspaceItemAdminWorkflowActionsComponent>;
|
||||||
|
let id;
|
||||||
|
let wsi;
|
||||||
|
let item = new Item();
|
||||||
|
item.uuid = 'itemUUID1111';
|
||||||
|
const rd = new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, item, 200);
|
||||||
|
let supervisionOrderDataService;
|
||||||
|
let notificationService: NotificationsServiceStub;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
notificationService = new NotificationsServiceStub();
|
||||||
|
supervisionOrderDataService = jasmine.createSpyObj('supervisionOrderDataService', {
|
||||||
|
searchByItem: jasmine.createSpy('searchByItem'),
|
||||||
|
delete: jasmine.createSpy('delete'),
|
||||||
|
});
|
||||||
|
id = '780b2588-bda5-4112-a1cd-0b15000a5339';
|
||||||
|
wsi = new WorkspaceItem();
|
||||||
|
wsi.id = id;
|
||||||
|
wsi.item = of(rd);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
init();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgbModalModule,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
RouterTestingModule.withRoutes([])
|
||||||
|
],
|
||||||
|
declarations: [WorkspaceItemAdminWorkflowActionsComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: DSONameService, useClass: DSONameServiceMock },
|
||||||
|
{ provide: NotificationsService, useValue: notificationService },
|
||||||
|
{ provide: SupervisionOrderDataService, useValue: supervisionOrderDataService }
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(WorkspaceItemAdminWorkflowActionsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.wsi = wsi;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render a delete button with the correct link', () => {
|
||||||
|
const button = fixture.debugElement.query(By.css('a.delete-link'));
|
||||||
|
const link = button.nativeElement.href;
|
||||||
|
expect(link).toContain(new URLCombiner(getWorkspaceItemDeleteRoute(wsi.id)).toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render a policies button with the correct link', () => {
|
||||||
|
const a = fixture.debugElement.query(By.css('a.policies-link'));
|
||||||
|
const link = a.nativeElement.href;
|
||||||
|
expect(link).toContain(new URLCombiner('/items/itemUUID1111/edit/authorizations').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteSupervisionOrder', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(component.delete, 'emit');
|
||||||
|
spyOn((component as any).modalService, 'open').and.returnValue({
|
||||||
|
componentInstance: { response: of(true) }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when delete succeeded', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
supervisionOrderDataService.delete.and.returnValue(of(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify success', () => {
|
||||||
|
component.deleteSupervisionOrder(supervisionOrderEntryMock);
|
||||||
|
expect((component as any).modalService.open).toHaveBeenCalledWith(ConfirmationModalComponent);
|
||||||
|
expect(notificationService.success).toHaveBeenCalled();
|
||||||
|
expect(component.delete.emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when delete failed', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
supervisionOrderDataService.delete.and.returnValue(of(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify success', () => {
|
||||||
|
component.deleteSupervisionOrder(supervisionOrderEntryMock);
|
||||||
|
expect((component as any).modalService.open).toHaveBeenCalledWith(ConfirmationModalComponent);
|
||||||
|
expect(notificationService.error).toHaveBeenCalled();
|
||||||
|
expect(component.delete.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openSupervisionModal', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(component.create, 'emit');
|
||||||
|
spyOn((component as any).modalService, 'open').and.returnValue({
|
||||||
|
componentInstance: { create: of(true) }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit create event properly', () => {
|
||||||
|
component.openSupervisionModal();
|
||||||
|
expect((component as any).modalService.open).toHaveBeenCalledWith(SupervisionOrderGroupSelectorComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
backdrop: 'static'
|
||||||
|
});
|
||||||
|
expect(component.create.emit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user