Merge remote-tracking branch 'dspace/main' into accessibility-settings-main

# Conflicts:
#	src/app/profile-page/profile-page.component.html
#	src/app/profile-page/profile-page.component.ts
#	src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts
#	src/app/shared/notifications/notifications-board/notifications-board.component.ts
#	src/themes/custom/app/profile-page/profile-page.component.ts
This commit is contained in:
Andreas Awouters
2025-01-23 09:53:46 +01:00
259 changed files with 10625 additions and 4305 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
# NOTE: will log all redux actions and transfers in console # NOTE: will log all redux actions and transfers in console
debug: false debug: false
# Angular Universal server settings # Angular User Inteface settings
# NOTE: these settings define where Node.js will start your UI application. Therefore, these # NOTE: these settings define where Node.js will start your UI application. Therefore, these
# "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar) # "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar)
ui: ui:
@@ -17,12 +17,14 @@ ui:
# Trust X-FORWARDED-* headers from proxies (default = true) # Trust X-FORWARDED-* headers from proxies (default = true)
useProxies: true useProxies: true
universal: # Angular Server Side Rendering (SSR) settings
# Whether to inline "critical" styles into the server-side rendered HTML. ssr:
# Determining which styles are critical is a relatively expensive operation; # Whether to tell Angular to inline "critical" styles into the server-side rendered HTML.
# this option can be disabled to boost server performance at the expense of # Determining which styles are critical is a relatively expensive operation; this option is
# loading smoothness. # disabled (false) by default to boost server performance at the expense of loading smoothness.
inlineCriticalCss: true inlineCriticalCss: false
# Path prefixes to enable SSR for. By default these are limited to paths of primary DSpace objects.
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ]
# The REST API server settings # The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually # NOTE: these settings define which (publicly available) REST API to use. They are usually

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

829
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -119,14 +119,14 @@
"@ngx-translate/core": "^14.0.0", "@ngx-translate/core": "^14.0.0",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^1.7.4", "axios": "^1.7.9",
"bootstrap": "^4.6.1", "bootstrap": "^4.6.1",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"colors": "^1.4.0", "colors": "^1.4.0",
"compression": "^1.7.4", "compression": "^1.7.5",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"core-js": "^3.38.1", "core-js": "^3.39.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7", "date-fns-tz": "^1.3.7",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
@@ -137,17 +137,16 @@
"filesize": "^6.1.0", "filesize": "^6.1.0",
"http-proxy-middleware": "^2.0.7", "http-proxy-middleware": "^2.0.7",
"http-terminator": "^3.2.0", "http-terminator": "^3.2.0",
"isbot": "^5.1.17", "isbot": "^5.1.21",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"jsonschema": "1.4.1", "jsonschema": "1.4.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lru-cache": "^7.14.1", "lru-cache": "^7.14.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"mirador": "^3.3.0", "mirador": "^3.4.2",
"mirador-dl-plugin": "^0.13.0", "mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.16.0", "mirador-share-plugin": "^0.16.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
@@ -157,6 +156,7 @@
"ngx-pagination": "6.0.3", "ngx-pagination": "6.0.3",
"ngx-ui-switch": "^14.1.0", "ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1", "nouislider": "^15.7.1",
"orejime": "^2.3.1",
"pem": "1.14.8", "pem": "1.14.8",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
@@ -177,7 +177,7 @@
"@angular/compiler-cli": "^17.3.11", "@angular/compiler-cli": "^17.3.11",
"@angular/language-service": "^17.3.12", "@angular/language-service": "^17.3.12",
"@cypress/schematic": "^1.5.0", "@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@ngrx/store-devtools": "^17.1.1", "@ngrx/store-devtools": "^17.1.1",
"@ngtools/webpack": "^16.2.16", "@ngtools/webpack": "^16.2.16",
"@types/deep-freeze": "0.1.5", "@types/deep-freeze": "0.1.5",
@@ -186,7 +186,7 @@
"@types/grecaptcha": "^3.0.9", "@types/grecaptcha": "^3.0.9",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.14",
"@types/node": "^14.14.9", "@types/node": "^14.14.9",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
@@ -196,7 +196,7 @@
"compression-webpack-plugin": "^9.2.0", "compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "^13.15.1", "cypress": "^13.17.0",
"cypress-axe": "^1.5.0", "cypress-axe": "^1.5.0",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"eslint": "^8.39.0", "eslint": "^8.39.0",
@@ -206,12 +206,12 @@
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0", "eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-jsonc": "^2.18.2",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"express-static-gzip": "^2.1.8", "express-static-gzip": "^2.2.0",
"jasmine": "^3.8.0", "jasmine": "^3.8.0",
"jasmine-core": "^3.8.0", "jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2", "jasmine-marbles": "0.9.2",
@@ -221,7 +221,7 @@
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"ng-mocks": "^14.13.1", "ng-mocks": "^14.13.2",
"ngx-mask": "14.2.4", "ngx-mask": "14.2.4",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"postcss": "^8.4", "postcss": "^8.4",
@@ -229,12 +229,12 @@
"postcss-loader": "^4.0.3", "postcss-loader": "^4.0.3",
"postcss-preset-env": "^7.4.2", "postcss-preset-env": "^7.4.2",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "~1.80.4", "sass": "~1.83.1",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"sass-resources-loader": "^2.2.5", "sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"typescript": "~5.4.5", "typescript": "~5.4.5",
"webpack": "5.95.0", "webpack": "5.97.1",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1" "webpack-dev-server": "^4.15.1"
} }

View File

@@ -218,7 +218,7 @@ export function app() {
* The callback function to serve server side angular * The callback function to serve server side angular
*/ */
function ngApp(req, res, next) { function ngApp(req, res, next) {
if (environment.ssr.enabled) { if (environment.ssr.enabled && req.method === 'GET' && (req.path === '/' || environment.ssr.paths.some(pathPrefix => req.path.startsWith(pathPrefix)))) {
// Render the page to user via SSR (server side rendering) // Render the page to user via SSR (server side rendering)
serverSideRender(req, res, next); serverSideRender(req, res, next);
} else { } else {

View File

@@ -61,7 +61,7 @@
</thead> </thead>
<tbody> <tbody>
<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' : (activeEPerson$ | async) === epersonDto.eperson}">
<td>{{epersonDto.eperson.id}}</td> <td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td> <td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td> <td>{{epersonDto.eperson.email}}</td>

View File

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

View File

@@ -2,7 +2,7 @@
<div class="group-form row"> <div class="group-form row">
<div class="col-12"> <div class="col-12">
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div> <div *ngIf="activeEPerson$ | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1> <h1 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h1>
@@ -44,7 +44,7 @@
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading> <ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading>
<div *ngIf="epersonService.getActiveEPerson() | async"> <div *ngIf="activeEPerson$ | async">
<h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2> <h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
<ds-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-loading> <ds-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-loading>
@@ -75,7 +75,9 @@
{{ dsoNameService.getName(group) }} {{ dsoNameService.getName(group) }}
</a> </a>
</td> </td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td> <td class="align-middle">
{{ dsoNameService.getName((group.object | async)?.payload) }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { import {
ComponentFixture, ComponentFixture,
TestBed, TestBed,
@@ -19,12 +19,10 @@ import {
import { import {
ActivatedRoute, ActivatedRoute,
Router, Router,
RouterModule,
} from '@angular/router'; } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { import { TranslateModule } from '@ngx-translate/core';
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { import {
Observable, Observable,
of as observableOf, of as observableOf,
@@ -49,7 +47,6 @@ import { FormBuilderService } from '../../../shared/form/builder/form-builder.se
import { FormComponent } from '../../../shared/form/form.component'; import { FormComponent } from '../../../shared/form/form.component';
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
@@ -92,9 +89,6 @@ describe('EPersonFormComponent', () => {
ePersonDataServiceStub = { ePersonDataServiceStub = {
activeEPerson: null, activeEPerson: null,
allEpeople: mockEPeople, allEpeople: mockEPeople,
getEPeople(): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList(null, this.allEpeople));
},
getActiveEPerson(): Observable<EPerson> { getActiveEPerson(): Observable<EPerson> {
return observableOf(this.activeEPerson); return observableOf(this.activeEPerson);
}, },
@@ -228,12 +222,8 @@ describe('EPersonFormComponent', () => {
router = new RouterStub(); router = new RouterStub();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ RouterModule.forRoot([]),
loader: { TranslateModule.forRoot(),
provide: TranslateLoader,
useClass: TranslateLoaderMock,
},
}),
EPersonFormComponent, EPersonFormComponent,
HasNoValuePipe, HasNoValuePipe,
], ],
@@ -251,7 +241,7 @@ describe('EPersonFormComponent', () => {
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
EPeopleRegistryComponent, EPeopleRegistryComponent,
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
}) })
.overrideComponent(EPersonFormComponent, { .overrideComponent(EPersonFormComponent, {
remove: { imports: [ ThemedLoadingComponent, PaginationComponent,FormComponent] }, remove: { imports: [ ThemedLoadingComponent, PaginationComponent,FormComponent] },
@@ -274,37 +264,13 @@ describe('EPersonFormComponent', () => {
}); });
describe('check form validation', () => { describe('check form validation', () => {
let firstName; let canLogIn: boolean;
let lastName; let requireCertificate: boolean;
let email;
let canLogIn;
let requireCertificate;
let expected;
beforeEach(() => { beforeEach(() => {
firstName = 'testName';
lastName = 'testLastName';
email = 'testEmail@test.com';
canLogIn = false; canLogIn = false;
requireCertificate = false; requireCertificate = false;
expected = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: firstName,
},
],
'eperson.lastname': [
{
value: lastName,
},
],
},
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit'); spyOn(component.submitForm, 'emit');
component.canLogIn.value = canLogIn; component.canLogIn.value = canLogIn;
component.requireCertificate.value = requireCertificate; component.requireCertificate.value = requireCertificate;
@@ -378,15 +344,13 @@ describe('EPersonFormComponent', () => {
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
}); });
}); });
}); });
describe('when submitting the form', () => { describe('when submitting the form', () => {
let firstName; let firstName;
let lastName; let lastName;
let email; let email;
let canLogIn; let canLogIn: boolean;
let requireCertificate; let requireCertificate;
let expected; let expected;
@@ -415,6 +379,7 @@ describe('EPersonFormComponent', () => {
requireCertificate: requireCertificate, requireCertificate: requireCertificate,
}); });
spyOn(component.submitForm, 'emit'); spyOn(component.submitForm, 'emit');
component.ngOnInit();
component.firstName.value = firstName; component.firstName.value = firstName;
component.lastName.value = lastName; component.lastName.value = lastName;
component.email.value = email; component.email.value = email;
@@ -454,9 +419,17 @@ describe('EPersonFormComponent', () => {
email: email, email: email,
canLogIn: canLogIn, canLogIn: canLogIn,
requireCertificate: requireCertificate, requireCertificate: requireCertificate,
_links: undefined, _links: {
groups: {
href: '',
},
self: {
href: '',
},
},
}); });
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
component.ngOnInit();
component.onSubmit(); component.onSubmit();
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -504,22 +477,19 @@ describe('EPersonFormComponent', () => {
}); });
describe('delete', () => { describe('delete', () => {
let ePersonId;
let eperson: EPerson; let eperson: EPerson;
let modalService; let modalService;
beforeEach(() => { beforeEach(() => {
spyOn(authService, 'impersonate').and.callThrough(); spyOn(authService, 'impersonate').and.callThrough();
ePersonId = 'testEPersonId';
eperson = EPersonMock; eperson = EPersonMock;
component.epersonInitial = eperson; component.epersonInitial = eperson;
component.canDelete$ = observableOf(true); component.canDelete$ = observableOf(true);
spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson)); spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson));
modalService = (component as any).modalService; modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
component.ngOnInit();
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the delete button should be visible if the ePerson can be deleted', () => { it('the delete button should be visible if the ePerson can be deleted', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,14 +27,14 @@
</thead> </thead>
<tbody> <tbody>
<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' : (activeMetadataSchema$ | async)?.id === schema.id}">
<td> <td>
<label class="mb-0"> <label class="mb-0">
<input type="checkbox" <input type="checkbox"
[checked]="isSelected(schema) | async" [checked]="(selectedMetadataSchemaIDs$ | async)?.includes(schema.id)"
(change)="selectMetadataSchema(schema, $event)" (change)="selectMetadataSchema(schema, $event)"
> >
<span class="sr-only">{{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span> <span class="sr-only">{{(((selectedMetadataSchemaIDs$ | async)?.includes(schema.id)) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}}</span>
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td> <td class="selectable-row" (click)="editSchema(schema)"><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import { slide } from '../../../shared/animations/slide';
import { MenuService } from '../../../shared/menu/menu.service'; import { MenuService } from '../../../shared/menu/menu.service';
import { MenuID } from '../../../shared/menu/menu-id.model'; import { MenuID } from '../../../shared/menu/menu-id.model';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component'; import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
/** /**
@@ -36,7 +37,7 @@ import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sid
styleUrls: ['./expandable-admin-sidebar-section.component.scss'], styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor], animations: [rotate, slide, bgColor],
standalone: true, standalone: true,
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule], imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule, BrowserOnlyPipe],
}) })
export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit { export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionComponent implements OnInit {

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ export const APP_ROUTES: Route[] = [
path: 'home', path: 'home',
loadChildren: () => import('./home-page/home-page-routes') loadChildren: () => import('./home-page/home-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { showBreadcrumbs: false }, data: { showBreadcrumbs: false, enableRSS: true },
providers: [provideSuggestionNotificationsState()], providers: [provideSuggestionNotificationsState()],
canActivate: [endUserAgreementCurrentUserGuard], canActivate: [endUserAgreementCurrentUserGuard],
}, },
@@ -101,12 +101,14 @@ export const APP_ROUTES: Route[] = [
path: COMMUNITY_MODULE_PATH, path: COMMUNITY_MODULE_PATH,
loadChildren: () => import('./community-page/community-page-routes') loadChildren: () => import('./community-page/community-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { enableRSS: true },
canActivate: [endUserAgreementCurrentUserGuard], canActivate: [endUserAgreementCurrentUserGuard],
}, },
{ {
path: COLLECTION_MODULE_PATH, path: COLLECTION_MODULE_PATH,
loadChildren: () => import('./collection-page/collection-page-routes') loadChildren: () => import('./collection-page/collection-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { showBreadcrumbs: false, enableRSS: true },
canActivate: [endUserAgreementCurrentUserGuard], canActivate: [endUserAgreementCurrentUserGuard],
}, },
{ {
@@ -137,6 +139,7 @@ export const APP_ROUTES: Route[] = [
path: 'mydspace', path: 'mydspace',
loadChildren: () => import('./my-dspace-page/my-dspace-page-routes') loadChildren: () => import('./my-dspace-page/my-dspace-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { enableRSS: true },
providers: [provideSuggestionNotificationsState()], providers: [provideSuggestionNotificationsState()],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard], canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
}, },
@@ -144,6 +147,7 @@ export const APP_ROUTES: Route[] = [
path: 'search', path: 'search',
loadChildren: () => import('./search-page/search-page-routes') loadChildren: () => import('./search-page/search-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { enableRSS: true },
canActivate: [endUserAgreementCurrentUserGuard], canActivate: [endUserAgreementCurrentUserGuard],
}, },
{ {
@@ -156,6 +160,7 @@ export const APP_ROUTES: Route[] = [
path: ADMIN_MODULE_PATH, path: ADMIN_MODULE_PATH,
loadChildren: () => import('./admin/admin-routes') loadChildren: () => import('./admin/admin-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { enableRSS: true },
canActivate: [siteAdministratorGuard, endUserAgreementCurrentUserGuard], canActivate: [siteAdministratorGuard, endUserAgreementCurrentUserGuard],
}, },
{ {
@@ -200,6 +205,7 @@ export const APP_ROUTES: Route[] = [
providers: [provideSubmissionState()], providers: [provideSubmissionState()],
loadChildren: () => import('./workflowitems-edit-page/workflowitems-edit-page-routes') loadChildren: () => import('./workflowitems-edit-page/workflowitems-edit-page-routes')
.then((m) => m.ROUTES), .then((m) => m.ROUTES),
data: { enableRSS: true },
canActivate: [endUserAgreementCurrentUserGuard], canActivate: [endUserAgreementCurrentUserGuard],
}, },
{ {

View File

@@ -19,7 +19,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { ResourcePoliciesComponent } from '../../shared/resource-policies/resource-policies.component'; import { ResourcePoliciesComponent } from '../../shared/resource-policies/resource-policies.component';
@Component({ @Component({
selector: 'ds-collection-authorizations', selector: 'ds-bitstream-authorizations',
templateUrl: './bitstream-authorizations.component.html', templateUrl: './bitstream-authorizations.component.html',
imports: [ imports: [
ResourcePoliciesComponent, ResourcePoliciesComponent,
@@ -30,7 +30,7 @@ import { ResourcePoliciesComponent } from '../../shared/resource-policies/resour
standalone: true, standalone: true,
}) })
/** /**
* Component that handles the Collection Authorizations * Component that handles the Bitstream Authorizations
*/ */
export class BitstreamAuthorizationsComponent<TDomain extends DSpaceObject> implements OnInit { export class BitstreamAuthorizationsComponent<TDomain extends DSpaceObject> implements OnInit {

View File

@@ -18,7 +18,10 @@ import {
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
} from 'rxjs'; } from 'rxjs';
import { map } from 'rxjs/operators'; import {
map,
take,
} from 'rxjs/operators';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component'; import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
import { import {
@@ -53,7 +56,6 @@ import { VarDirective } from '../../shared/utils/var.directive';
import { import {
BrowseByMetadataComponent, BrowseByMetadataComponent,
browseParamsToOptions, browseParamsToOptions,
getBrowseSearchOptions,
} from '../browse-by-metadata/browse-by-metadata.component'; } from '../browse-by-metadata/browse-by-metadata.component';
@Component({ @Component({
@@ -104,15 +106,18 @@ export class BrowseByDateComponent extends BrowseByMetadataComponent implements
ngOnInit(): void { ngOnInit(): void {
const sortConfig = new SortOptions('default', SortDirection.ASC); const sortConfig = new SortOptions('default', SortDirection.ASC);
this.startsWithType = StartsWithType.date; this.startsWithType = StartsWithType.date;
// include the thumbnail configuration in browse search options
this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails));
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);
this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig);
this.subs.push( this.subs.push(
observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.route.data, observableCombineLatest(
this.currentPagination$, this.currentSort$]).pipe( [ this.route.params.pipe(take(1)),
map(([routeParams, queryParams, scope, data, currentPage, currentSort]) => { this.route.queryParams,
return [Object.assign({}, routeParams, queryParams, data), scope, currentPage, currentSort]; this.scope$,
this.currentPagination$,
this.currentSort$,
]).pipe(
map(([routeParams, queryParams, scope, currentPage, currentSort]) => {
return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort];
}), }),
).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => {
const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys;

View File

@@ -23,7 +23,10 @@ import {
of as observableOf, of as observableOf,
Subscription, Subscription,
} from 'rxjs'; } from 'rxjs';
import { map } from 'rxjs/operators'; import {
map,
take,
} from 'rxjs/operators';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component'; import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
import { import {
@@ -216,7 +219,13 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);
this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig);
this.subs.push( this.subs.push(
observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( observableCombineLatest(
[ this.route.params.pipe(take(1)),
this.route.queryParams,
this.scope$,
this.currentPagination$,
this.currentSort$,
]).pipe(
map(([routeParams, queryParams, scope, currentPage, currentSort]) => { map(([routeParams, queryParams, scope, currentPage, currentSort]) => {
return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort];
}), }),

View File

@@ -1,6 +1,5 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver'; import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
import { browseByGuard } from './browse-by-guard'; import { browseByGuard } from './browse-by-guard';
import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
@@ -11,7 +10,6 @@ export const ROUTES: Route[] = [
path: '', path: '',
resolve: { resolve: {
breadcrumb: browseByDSOBreadcrumbResolver, breadcrumb: browseByDSOBreadcrumbResolver,
menu: dsoEditMenuResolver,
}, },
children: [ children: [
{ {

View File

@@ -23,7 +23,7 @@ import { BrowseBySwitcherComponent } from '../browse-by-switcher/browse-by-switc
}) })
export class BrowseByPageComponent implements OnInit { export class BrowseByPageComponent implements OnInit {
browseByType$: Observable<BrowseByDataType>; browseByType$: Observable<{type: BrowseByDataType }>;
constructor( constructor(
protected route: ActivatedRoute, protected route: ActivatedRoute,
@@ -35,7 +35,7 @@ export class BrowseByPageComponent implements OnInit {
*/ */
ngOnInit(): void { ngOnInit(): void {
this.browseByType$ = this.route.data.pipe( this.browseByType$ = this.route.data.pipe(
map((data: { browseDefinition: BrowseDefinition }) => data.browseDefinition.getRenderType()), map((data: { browseDefinition: BrowseDefinition }) => ({ type: data.browseDefinition.getRenderType() })),
); );
} }

View File

@@ -85,7 +85,7 @@ describe('BrowseBySwitcherComponent', () => {
types.forEach((type: NonHierarchicalBrowseDefinition) => { types.forEach((type: NonHierarchicalBrowseDefinition) => {
describe(`when switching to a browse-by page for "${type.id}"`, () => { describe(`when switching to a browse-by page for "${type.id}"`, () => {
beforeEach(async () => { beforeEach(async () => {
comp.browseByType = type.dataType; comp.browseByType = type as any;
comp.ngOnChanges({ comp.ngOnChanges({
browseByType: new SimpleChange(undefined, type.dataType, true), browseByType: new SimpleChange(undefined, type.dataType, true),
}); });

View File

@@ -24,7 +24,7 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent<
@Input() context: Context; @Input() context: Context;
@Input() browseByType: BrowseByDataType; @Input() browseByType: { type: BrowseByDataType };
@Input() displayTitle: boolean; @Input() displayTitle: boolean;
@@ -43,7 +43,7 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent<
]; ];
public getComponent(): GenericConstructor<Component> { public getComponent(): GenericConstructor<Component> {
return getComponentByBrowseByType(this.browseByType, this.context, this.themeService.getThemeName()); return getComponentByBrowseByType(this.browseByType.type, this.context, this.themeService.getThemeName());
} }
} }

View File

@@ -9,7 +9,10 @@ import {
import { Params } from '@angular/router'; import { Params } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest } from 'rxjs'; import { combineLatest as observableCombineLatest } from 'rxjs';
import { map } from 'rxjs/operators'; import {
map,
take,
} from 'rxjs/operators';
import { import {
SortDirection, SortDirection,
@@ -28,7 +31,6 @@ import { VarDirective } from '../../shared/utils/var.directive';
import { import {
BrowseByMetadataComponent, BrowseByMetadataComponent,
browseParamsToOptions, browseParamsToOptions,
getBrowseSearchOptions,
} from '../browse-by-metadata/browse-by-metadata.component'; } from '../browse-by-metadata/browse-by-metadata.component';
@Component({ @Component({
@@ -58,12 +60,16 @@ export class BrowseByTitleComponent extends BrowseByMetadataComponent implements
ngOnInit(): void { ngOnInit(): void {
const sortConfig = new SortOptions('dc.title', SortDirection.ASC); const sortConfig = new SortOptions('dc.title', SortDirection.ASC);
// include the thumbnail configuration in browse search options
this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails));
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);
this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig);
this.subs.push( this.subs.push(
observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( observableCombineLatest(
[ this.route.params.pipe(take(1)),
this.route.queryParams,
this.scope$,
this.currentPagination$,
this.currentSort$,
]).pipe(
map(([routeParams, queryParams, scope, currentPage, currentSort]) => { map(([routeParams, queryParams, scope, currentPage, currentSort]) => {
return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort];
}), }),

View File

@@ -23,7 +23,7 @@
</div> </div>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="'mapTab'" role="presentation" data-test="mapTab"> <li [ngbNavItem]="'mapTab' | dsBrowserOnly" role="presentation" data-test="mapTab">
<a ngbNavLink>{{'collection.edit.item-mapper.tabs.map' | translate}}</a> <a ngbNavLink>{{'collection.edit.item-mapper.tabs.map' | translate}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row mt-2"> <div class="row mt-2">

View File

@@ -62,6 +62,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { ItemSelectComponent } from '../../shared/object-select/item-select/item-select.component'; import { ItemSelectComponent } from '../../shared/object-select/item-select/item-select.component';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { ThemedSearchFormComponent } from '../../shared/search-form/themed-search-form.component'; import { ThemedSearchFormComponent } from '../../shared/search-form/themed-search-form.component';
import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
@Component({ @Component({
@@ -86,6 +87,7 @@ import { followLink } from '../../shared/utils/follow-link-config.model';
AsyncPipe, AsyncPipe,
ItemSelectComponent, ItemSelectComponent,
NgIf, NgIf,
BrowserOnlyPipe,
], ],
standalone: true, standalone: true,
}) })

View File

@@ -54,7 +54,6 @@ export const ROUTES: Route[] = [
resolve: { resolve: {
dso: collectionPageResolver, dso: collectionPageResolver,
breadcrumb: collectionBreadcrumbResolver, breadcrumb: collectionBreadcrumbResolver,
menu: dsoEditMenuResolver,
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [
@@ -83,6 +82,9 @@ export const ROUTES: Route[] = [
{ {
path: '', path: '',
component: ThemedCollectionPageComponent, component: ThemedCollectionPageComponent,
resolve: {
menu: dsoEditMenuResolver,
},
children: [ children: [
{ {
path: '', path: '',

View File

@@ -1,8 +1,8 @@
<div class="container" *ngIf="(isLoading$ | async) === false"> <div class="container" *ngIf="(isLoading$ | async) === false">
<div class="row"> <div class="row">
<div class="col-12 pb-4"> <div class="col-12 pb-4">
<h2 id="sub-header" <h1 id="sub-header"
class="border-bottom pb-2">{{ 'collection.create.sub-head' | translate:{ parent: dsoNameService.getName((parentRD$| async)?.payload) } }}</h2> class="border-bottom pb-2">{{ 'collection.create.sub-head' | translate:{ parent: dsoNameService.getName((parentRD$| async)?.payload) } }}</h1>
</div> </div>
</div> </div>
<ds-collection-form (submitForm)="onSubmit($event)" <ds-collection-form (submitForm)="onSubmit($event)"

View File

@@ -51,7 +51,6 @@ export const ROUTES: Route[] = [
resolve: { resolve: {
dso: communityPageResolver, dso: communityPageResolver,
breadcrumb: communityBreadcrumbResolver, breadcrumb: communityBreadcrumbResolver,
menu: dsoEditMenuResolver,
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [
@@ -70,6 +69,9 @@ export const ROUTES: Route[] = [
{ {
path: '', path: '',
component: ThemedCommunityPageComponent, component: ThemedCommunityPageComponent,
resolve: {
menu: dsoEditMenuResolver,
},
children: [ children: [
{ {
path: '', path: '',

View File

@@ -2,9 +2,9 @@
<div class="row"> <div class="row">
<div class="col-12 pb-4"> <div class="col-12 pb-4">
<ng-container *ngVar="(parentRD$ | async)?.payload as parent"> <ng-container *ngVar="(parentRD$ | async)?.payload as parent">
<h2 *ngIf="!parent" id="header" class="border-bottom p-2">{{ 'community.create.head' | translate }}</h2> <h1 *ngIf="!parent" id="header" class="border-bottom p-2">{{ 'community.create.head' | translate }}</h1>
<h2 *ngIf="parent" id="sub-header" <h1 *ngIf="parent" id="sub-header"
class="border-bottom pb-2">{{ 'community.create.sub-head' | translate:{ parent: dsoNameService.getName(parent) } }}</h2> class="border-bottom pb-2">{{ 'community.create.sub-head' | translate:{ parent: dsoNameService.getName(parent) } }}</h1>
</ng-container> </ng-container>
</div> </div>
</div> </div>

View File

@@ -31,10 +31,12 @@ class TestModel implements HALResource {
self: HALLink; self: HALLink;
predecessor: HALLink; predecessor: HALLink;
successor: HALLink; successor: HALLink;
standardLinkName: HALLink;
}; };
predecessor?: TestModel; predecessor?: TestModel;
successor?: TestModel; successor?: TestModel;
renamedProperty?: TestModel;
} }
const mockDataServiceMap: any = new Map([ const mockDataServiceMap: any = new Map([
@@ -66,6 +68,24 @@ describe('LinkService', () => {
testDataService = new TestDataService(); testDataService = new TestDataService();
spyOn(testDataService, 'findListByHref').and.callThrough(); spyOn(testDataService, 'findListByHref').and.callThrough();
spyOn(testDataService, 'findByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough();
const linksDefinitions = new Map();
linksDefinitions.set('predecessor', {
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor',
});
linksDefinitions.set('successor', {
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor',
});
linksDefinitions.set('standardLinkName', {
resourceType: TEST_MODEL,
linkName: 'standardLinkName',
propertyName: 'renamedProperty',
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
LinkService, LinkService,
@@ -87,18 +107,7 @@ describe('LinkService', () => {
}, },
{ {
provide: LINK_DEFINITION_MAP_FACTORY, provide: LINK_DEFINITION_MAP_FACTORY,
useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue([ useValue: jasmine.createSpy('getLinkDefinitions').and.returnValue(linksDefinitions),
{
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor',
},
{
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor',
},
]),
}, },
], ],
}); });
@@ -117,6 +126,15 @@ describe('LinkService', () => {
}); });
}); });
}); });
describe(`when the propertyName is different than linkName`, () => {
beforeEach(() => {
result = service.resolveLink(testModel, followLink('standardLinkName', {}));
});
it('link should be assign to custom property', () => {
expect(result.renamedProperty).toBeDefined();
expect(result.standardLinkName).toBeUndefined();
});
});
describe(`when the linkdefinition concerns a list`, () => { describe(`when the linkdefinition concerns a list`, () => {
beforeEach(() => { beforeEach(() => {
((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({ ((service as any).getLinkDefinition as jasmine.Spy).and.returnValue({

View File

@@ -112,7 +112,16 @@ export class LinkService {
* @param linkToFollow the {@link FollowLinkConfig} to resolve * @param linkToFollow the {@link FollowLinkConfig} to resolve
*/ */
public resolveLink<T extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): T { public resolveLink<T extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): T {
model[linkToFollow.name] = this.resolveLinkWithoutAttaching(model, linkToFollow); const linkDefinitions = this.getLinkDefinitions(model.constructor as GenericConstructor<T>);
const linkDef = linkDefinitions.get(linkToFollow.name);
if (isNotEmpty(linkDef)) {
// If link exist in definition we can resolve it and use a real property name
model[linkDef.propertyName] = this.resolveLinkWithoutAttaching(model, linkToFollow);
} else {
// For some links we don't have a definition, so we use the link name as property name
model[linkToFollow.name] = this.resolveLinkWithoutAttaching(model, linkToFollow);
}
return model; return model;
} }

View File

@@ -4,20 +4,16 @@ import {
inheritSerialization, inheritSerialization,
} from 'cerialize'; } from 'cerialize';
import {
SectionScope,
SectionVisibility,
} from '../../../submission/objects/section-visibility.model';
import { SectionsType } from '../../../submission/sections/sections-type'; import { SectionsType } from '../../../submission/sections/sections-type';
import { typedObject } from '../../cache/builders/build-decorators'; import { typedObject } from '../../cache/builders/build-decorators';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { ConfigObject } from './config.model'; import { ConfigObject } from './config.model';
import { SUBMISSION_SECTION_TYPE } from './config-type'; import { SUBMISSION_SECTION_TYPE } from './config-type';
/**
* An interface that define section visibility and its properties.
*/
export interface SubmissionSectionVisibility {
main: any;
other: any;
}
@typedObject @typedObject
@inheritSerialization(ConfigObject) @inheritSerialization(ConfigObject)
export class SubmissionSectionModel extends ConfigObject { export class SubmissionSectionModel extends ConfigObject {
@@ -35,6 +31,12 @@ export class SubmissionSectionModel extends ConfigObject {
@autoserialize @autoserialize
mandatory: boolean; mandatory: boolean;
/**
* The submission scope for this section
*/
@autoserialize
scope: SectionScope;
/** /**
* A string representing the kind of section object * A string representing the kind of section object
*/ */
@@ -42,10 +44,10 @@ export class SubmissionSectionModel extends ConfigObject {
sectionType: SectionsType; sectionType: SectionsType;
/** /**
* The [SubmissionSectionVisibility] object for this section * The [SectionVisibility] object for this section
*/ */
@autoserialize @autoserialize
visibility: SubmissionSectionVisibility; visibility: SectionVisibility;
/** /**
* The {@link HALLink}s for this SubmissionSectionModel * The {@link HALLink}s for this SubmissionSectionModel

View File

@@ -5,6 +5,7 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
// eslint-disable-next-line max-classes-per-file
import { import {
fakeAsync, fakeAsync,
tick, tick,
@@ -26,12 +27,20 @@ import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-ser
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
import {
link,
typedObject,
} from '../../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service'; import { ObjectCacheService } from '../../cache/object-cache.service';
import { BITSTREAM } from '../../shared/bitstream.resource-type';
import { COLLECTION } from '../../shared/collection.resource-type';
import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { ResourceType } from '../../shared/resource-type';
import { FindListOptions } from '../find-list-options.model'; import { FindListOptions } from '../find-list-options.model';
import { PaginatedList } from '../paginated-list.model';
import { RemoteData } from '../remote-data'; import { RemoteData } from '../remote-data';
import { RequestService } from '../request.service'; import { RequestService } from '../request.service';
import { RequestEntryState } from '../request-entry-state.model'; import { RequestEntryState } from '../request-entry-state.model';
@@ -56,6 +65,25 @@ class TestService extends BaseDataService<any> {
} }
} }
@typedObject
class BaseData {
static type = new ResourceType('test');
foo: string;
_links: {
followLink1: HALLink;
followLink2: HALLink[];
self: HALLink;
};
@link(COLLECTION)
followLink1: Observable<any>;
@link(BITSTREAM, true, 'followLink2')
followLink2CustomVariableName: Observable<PaginatedList<any>>;
}
describe('BaseDataService', () => { describe('BaseDataService', () => {
let service: TestService; let service: TestService;
let requestService; let requestService;
@@ -65,8 +93,9 @@ describe('BaseDataService', () => {
let selfLink; let selfLink;
let linksToFollow; let linksToFollow;
let testScheduler; let testScheduler;
let remoteDataMocks: { [responseType: string]: RemoteData<any> }; let remoteDataTimestamp: number;
let remoteDataPageMocks: { [responseType: string]: RemoteData<any> }; let remoteDataMocks: { [responseType: string]: RemoteData<BaseData> };
let remoteDataPageMocks: { [responseType: string]: RemoteData<PaginatedList<BaseData>> };
function initTestService(): TestService { function initTestService(): TestService {
requestService = getMockRequestService(); requestService = getMockRequestService();
@@ -85,12 +114,14 @@ describe('BaseDataService', () => {
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
const timeStamp = new Date().getTime(); // The response's lastUpdated equals the time of 60 seconds after the test started, ensuring they are not perceived
// as cached values.
remoteDataTimestamp = new Date().getTime() + 60 * 1000;
const msToLive = 15 * 60 * 1000; const msToLive = 15 * 60 * 1000;
const payload = { const payload: BaseData = Object.assign(new BaseData(), {
foo: 'bar', foo: 'bar',
followLink1: {}, followLink1: observableOf({}),
followLink2: {}, followLink2CustomVariableName: observableOf(createPaginatedList()),
_links: { _links: {
self: Object.assign(new HALLink(), { self: Object.assign(new HALLink(), {
href: 'self-test-link', href: 'self-test-link',
@@ -107,27 +138,27 @@ describe('BaseDataService', () => {
}), }),
], ],
}, },
}; });
const statusCodeSuccess = 200; const statusCodeSuccess = 200;
const statusCodeError = 404; const statusCodeError = 404;
const errorMessage = 'not found'; const errorMessage = 'not found';
remoteDataMocks = { remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
}; };
remoteDataPageMocks = { remoteDataPageMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
}; };
return new TestService( return new TestService(
@@ -361,11 +392,15 @@ describe('BaseDataService', () => {
spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source);
}); });
it('should not emit a cached completed RemoteData', () => {
it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { // Old cached value from 1 minute before the test started
const oldCachedSucceededData: RemoteData<any> = Object.assign({}, remoteDataPageMocks.Success, {
timeCompleted: remoteDataTimestamp - 2 * 60 * 1000,
lastUpdated: remoteDataTimestamp - 2 * 60 * 1000,
} as RemoteData<any>);
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', {
a: remoteDataMocks.Success, a: oldCachedSucceededData,
b: remoteDataMocks.RequestPending, b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending, c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success, d: remoteDataMocks.Success,
@@ -383,6 +418,22 @@ describe('BaseDataService', () => {
}); });
}); });
it('should emit the first completed RemoteData since the request was made', () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b', {
a: remoteDataMocks.Success,
b: remoteDataMocks.SuccessStale,
}));
const expected = 'a-b';
const values = {
a: remoteDataMocks.Success,
b: remoteDataMocks.SuccessStale,
};
expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values);
});
});
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', {
@@ -411,17 +462,12 @@ describe('BaseDataService', () => {
it('should link all the followLinks of a cached object by calling addDependency', () => { it('should link all the followLinks of a cached object by calling addDependency', () => {
spyOn(objectCache, 'addDependency').and.callThrough(); spyOn(objectCache, 'addDependency').and.callThrough();
testScheduler.run(({ cold, expectObservable, flush }) => { testScheduler.run(({ cold, expectObservable, flush }) => {
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', {
a: remoteDataMocks.Success, a: remoteDataMocks.Success,
b: remoteDataMocks.RequestPending,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
})); }));
const expected = '--b-c-d'; const expected = 'a';
const values = { const values = {
b: remoteDataMocks.RequestPending, a: remoteDataMocks.Success,
c: remoteDataMocks.ResponsePending,
d: remoteDataMocks.Success,
}; };
expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values);
@@ -570,11 +616,15 @@ describe('BaseDataService', () => {
spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source);
}); });
it('should not emit a cached completed RemoteData', () => {
it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
// Old cached value from 1 minute before the test started
const oldCachedSucceededData: RemoteData<any> = Object.assign({}, remoteDataPageMocks.Success, {
timeCompleted: remoteDataTimestamp - 2 * 60 * 1000,
lastUpdated: remoteDataTimestamp - 2 * 60 * 1000,
} as RemoteData<any>);
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', {
a: remoteDataPageMocks.Success, a: oldCachedSucceededData,
b: remoteDataPageMocks.RequestPending, b: remoteDataPageMocks.RequestPending,
c: remoteDataPageMocks.ResponsePending, c: remoteDataPageMocks.ResponsePending,
d: remoteDataPageMocks.Success, d: remoteDataPageMocks.Success,
@@ -592,6 +642,22 @@ describe('BaseDataService', () => {
}); });
}); });
it('should emit the first completed RemoteData since the request was made', () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b', {
a: remoteDataPageMocks.Success,
b: remoteDataPageMocks.SuccessStale,
}));
const expected = 'a-b';
const values = {
a: remoteDataPageMocks.Success,
b: remoteDataPageMocks.SuccessStale,
};
expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values);
});
});
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', {

View File

@@ -28,11 +28,16 @@ import {
isNotEmptyOperator, isNotEmptyOperator,
} from '../../../shared/empty.util'; } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import {
getLinkDefinition,
LinkDefinition,
} from '../../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { CacheableObject } from '../../cache/cacheable-object.model'; import { CacheableObject } from '../../cache/cacheable-object.model';
import { RequestParam } from '../../cache/models/request-param.model'; import { RequestParam } from '../../cache/models/request-param.model';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service'; import { ObjectCacheService } from '../../cache/object-cache.service';
import { GenericConstructor } from '../../shared/generic-constructor';
import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { getFirstCompletedRemoteData } from '../../shared/operators'; import { getFirstCompletedRemoteData } from '../../shared/operators';
@@ -285,6 +290,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)),
); );
const startTime: number = new Date().getTime();
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
const response$: Observable<RemoteData<T>> = this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe( const response$: Observable<RemoteData<T>> = this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
@@ -292,7 +298,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
// call it isn't immediately returned, but we wait until the remote data for the new request // call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object // cached completed object
skipWhile((rd: RemoteData<T>) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), skipWhile((rd: RemoteData<T>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)),
this.reRequestStaleRemoteData(reRequestOnStale, () => this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
); );
@@ -300,9 +306,10 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
tap((remoteDataObject: RemoteData<T>) => { tap((remoteDataObject: RemoteData<T>) => {
if (hasValue(remoteDataObject?.payload?._links)) { if (hasValue(remoteDataObject?.payload?._links)) {
for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { for (const followLinkName of Object.keys(remoteDataObject.payload._links) as (keyof typeof remoteDataObject.payload._links)[]) {
// only add the followLinks if they are embedded // only add the followLinks if they are embedded, and we get only links from the linkMap with the correct name
if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { const linkDefinition: LinkDefinition<T> = getLinkDefinition(remoteDataObject.payload.constructor as GenericConstructor<T>, followLinkName);
if (linkDefinition?.propertyName && hasValue(remoteDataObject.payload[linkDefinition.propertyName]) && followLinkName !== 'self') {
// followLink can be either an individual HALLink or a HALLink[] // followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]);
for (const individualFollowLink of followLinksList) { for (const individualFollowLink of followLinksList) {
@@ -338,6 +345,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)),
); );
const startTime: number = new Date().getTime();
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
const response$: Observable<RemoteData<PaginatedList<T>>> = this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe( const response$: Observable<RemoteData<PaginatedList<T>>> = this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
@@ -345,7 +353,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
// call it isn't immediately returned, but we wait until the remote data for the new request // call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object // cached completed object
skipWhile((rd: RemoteData<PaginatedList<T>>) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), skipWhile((rd: RemoteData<PaginatedList<T>>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)),
this.reRequestStaleRemoteData(reRequestOnStale, () => this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
); );
@@ -355,9 +363,10 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
if (hasValue(remoteDataObject?.payload?.page)) { if (hasValue(remoteDataObject?.payload?.page)) {
for (const object of remoteDataObject.payload.page) { for (const object of remoteDataObject.payload.page) {
if (hasValue(object?._links)) { if (hasValue(object?._links)) {
for (const followLinkName of Object.keys(object._links)) { for (const followLinkName of Object.keys(object._links) as (keyof typeof object._links)[]) {
// only add the followLinks if they are embedded // only add the followLinks if they are embedded, and we get only links from the linkMap with the correct name
if (hasValue(object[followLinkName]) && followLinkName !== 'self') { const linkDefinition: LinkDefinition<PaginatedList<T>> = getLinkDefinition(object.constructor as GenericConstructor<PaginatedList<T>>, followLinkName);
if (linkDefinition?.propertyName && followLinkName !== 'self' && hasValue(object[linkDefinition.propertyName])) {
// followLink can be either an individual HALLink or a HALLink[] // followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); const followLinksList: HALLink[] = [].concat(object._links[followLinkName]);
for (const individualFollowLink of followLinksList) { for (const individualFollowLink of followLinksList) {

View File

@@ -0,0 +1,28 @@
export class ObjectUpdatesServiceStub {
initialize = jasmine.createSpy('initialize');
saveFieldUpdate = jasmine.createSpy('saveFieldUpdate');
getObjectEntry = jasmine.createSpy('getObjectEntry');
getFieldState = jasmine.createSpy('getFieldState');
getFieldUpdates = jasmine.createSpy('getFieldUpdates');
getFieldUpdatesExclusive = jasmine.createSpy('getFieldUpdatesExclusive');
isValid = jasmine.createSpy('isValid');
isValidPage = jasmine.createSpy('isValidPage');
saveAddFieldUpdate = jasmine.createSpy('saveAddFieldUpdate');
saveRemoveFieldUpdate = jasmine.createSpy('saveRemoveFieldUpdate');
saveChangeFieldUpdate = jasmine.createSpy('saveChangeFieldUpdate');
isSelectedVirtualMetadata = jasmine.createSpy('isSelectedVirtualMetadata');
setSelectedVirtualMetadata = jasmine.createSpy('setSelectedVirtualMetadata');
setEditableFieldUpdate = jasmine.createSpy('setEditableFieldUpdate');
setValidFieldUpdate = jasmine.createSpy('setValidFieldUpdate');
discardFieldUpdates = jasmine.createSpy('discardFieldUpdates');
discardAllFieldUpdates = jasmine.createSpy('discardAllFieldUpdates');
reinstateFieldUpdates = jasmine.createSpy('reinstateFieldUpdates');
removeSingleFieldUpdate = jasmine.createSpy('removeSingleFieldUpdate');
getUpdateFields = jasmine.createSpy('getUpdateFields');
hasUpdates = jasmine.createSpy('hasUpdates');
isReinstatable = jasmine.createSpy('isReinstatable');
getLastModified = jasmine.createSpy('getLastModified');
createPatch = jasmine.createSpy('getPatch');
}

View File

@@ -30,7 +30,7 @@ describe('GoogleRecaptchaService', () => {
findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['googleRecaptchaToken'] }), findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['googleRecaptchaToken'] }),
}); });
cookieService = jasmine.createSpyObj('cookieService', { cookieService = jasmine.createSpyObj('cookieService', {
get: '{%22token_item%22:true%2C%22impersonation%22:true%2C%22redirect%22:true%2C%22language%22:true%2C%22klaro%22:true%2C%22has_agreed_end_user%22:true%2C%22google-analytics%22:true}', get: '{%22token_item%22:true%2C%22impersonation%22:true%2C%22redirect%22:true%2C%22language%22:true%2C%22orejime%22:true%2C%22has_agreed_end_user%22:true%2C%22google-analytics%22:true}',
set: () => { set: () => {
/* empty */ /* empty */
}, },

View File

@@ -105,7 +105,7 @@ export class GoogleRecaptchaService {
tap(([recaptchaVersionRD, recaptchaModeRD, recaptchaKeyRD]) => { tap(([recaptchaVersionRD, recaptchaModeRD, recaptchaKeyRD]) => {
if ( if (
this.cookieService.get('klaro-anonymous') && this.cookieService.get('klaro-anonymous')[CAPTCHA_NAME] && this.cookieService.get('orejime-anonymous') && this.cookieService.get('orejime-anonymous')[CAPTCHA_NAME] &&
recaptchaKeyRD.hasSucceeded && recaptchaVersionRD.hasSucceeded && recaptchaKeyRD.hasSucceeded && recaptchaVersionRD.hasSucceeded &&
isNotEmpty(recaptchaVersionRD.payload?.values) && isNotEmpty(recaptchaKeyRD.payload?.values) isNotEmpty(recaptchaVersionRD.payload?.values) && isNotEmpty(recaptchaKeyRD.payload?.values)
) { ) {

View File

@@ -27,7 +27,7 @@ export class MetadataService {
* Returns undefined otherwise. * Returns undefined otherwise.
*/ */
public virtualValue(metadataValue: MetadataValue | undefined): string { public virtualValue(metadataValue: MetadataValue | undefined): string {
if (this.isVirtual) { if (this.isVirtual(metadataValue)) {
return metadataValue.authority.substring(metadataValue.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length); return metadataValue.authority.substring(metadataValue.authority.indexOf(VIRTUAL_METADATA_PREFIX) + VIRTUAL_METADATA_PREFIX.length);
} else { } else {
return undefined; return undefined;

View File

@@ -16,6 +16,7 @@ import { AccessStatusObject } from '../shared/object-collection/shared/badges/ac
import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model';
import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { Subscription } from '../shared/subscriptions/models/subscription.model';
import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config';
import { SystemWideAlert } from '../system-wide-alert/system-wide-alert.model';
import { AuthStatus } from './auth/models/auth-status.model'; import { AuthStatus } from './auth/models/auth-status.model';
import { ShortLivedToken } from './auth/models/short-lived-token.model'; import { ShortLivedToken } from './auth/models/short-lived-token.model';
import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model';
@@ -186,4 +187,5 @@ export const models =
Itemfilter, Itemfilter,
SubmissionCoarNotifyConfig, SubmissionCoarNotifyConfig,
NotifyRequestsStatus, NotifyRequestsStatus,
SystemWideAlert,
]; ];

View File

@@ -1,4 +1,7 @@
import { autoserialize } from 'cerialize'; import {
autoserialize,
autoserializeAs,
} from 'cerialize';
import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type'; import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-data-type';
import { CacheableObject } from '../cache/cacheable-object.model'; import { CacheableObject } from '../cache/cacheable-object.model';
@@ -11,6 +14,9 @@ export abstract class BrowseDefinition extends CacheableObject {
@autoserialize @autoserialize
id: string; id: string;
@autoserializeAs('metadata')
metadataKeys: string[];
/** /**
* Get the render type of the BrowseDefinition model * Get the render type of the BrowseDefinition model
*/ */

View File

@@ -1,6 +1,5 @@
import { import {
autoserialize, autoserialize,
autoserializeAs,
deserialize, deserialize,
inheritSerialization, inheritSerialization,
} from 'cerialize'; } from 'cerialize';
@@ -33,9 +32,6 @@ export class HierarchicalBrowseDefinition extends BrowseDefinition {
@autoserialize @autoserialize
vocabulary: string; vocabulary: string;
@autoserializeAs('metadata')
metadataKeys: string[];
get self(): string { get self(): string {
return this._links.self.href; return this._links.self.href;
} }

View File

@@ -77,7 +77,7 @@ export class Metadata {
/** /**
* Gets the first matching MetadataValue object in the map(s), or `undefined`. * Gets the first matching MetadataValue object in the map(s), or `undefined`.
* *
* @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {MetadataMapInterface|MetadataMapInterface[]} mdMapOrMaps The source map(s).
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
* @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
* @returns {MetadataValue} the first matching value, or `undefined`. * @returns {MetadataValue} the first matching value, or `undefined`.
@@ -98,7 +98,7 @@ export class Metadata {
/** /**
* Like [[Metadata.first]], but only returns a string value, or `undefined`. * Like [[Metadata.first]], but only returns a string value, or `undefined`.
* *
* @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {MetadataMapInterface|MetadataMapInterface[]} mdMapOrMaps The source map(s).
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
* @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
* @returns {string} the first matching string value, or `undefined`. * @returns {string} the first matching string value, or `undefined`.
@@ -112,7 +112,7 @@ export class Metadata {
/** /**
* Checks for a matching metadata value in the given map(s). * Checks for a matching metadata value in the given map(s).
* *
* @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {MetadataMapInterface|MetadataMapInterface[]} mdMapOrMaps The source map(s).
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
* @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done.
* @returns {boolean} whether a match is found. * @returns {boolean} whether a match is found.

View File

@@ -21,9 +21,6 @@ export abstract class NonHierarchicalBrowseDefinition extends BrowseDefinition {
@autoserializeAs('order') @autoserializeAs('order')
defaultSortOrder: string; defaultSortOrder: string;
@autoserializeAs('metadata')
metadataKeys: string[];
@autoserialize @autoserialize
dataType: BrowseByDataType; dataType: BrowseByDataType;
} }

View File

@@ -3,20 +3,26 @@
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }"> [ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell"> <div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div> <div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && (isAuthorityControlled() | async) !== true" [(ngModel)]="mdValue.newValue.value" <textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && ((isAuthorityControlled() | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary() | async) !== true))" [(ngModel)]="mdValue.newValue.value"
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate" [attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea> [dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
<ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async)" <ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async) && !enabledFreeTextEditing"
[bindId]="mdField" [bindId]="mdField"
[group]="group" [group]="group"
[model]="getModel()" [model]="getModel()"
(change)="onChangeAuthorityField($event)"> (change)="onChangeAuthorityField($event)">
</ds-dynamic-scrollable-dropdown> </ds-dynamic-scrollable-dropdown>
<ds-dynamic-onebox *ngIf="mdValue.editing && ((isHierarchicalVocabulary() | async) || (isSuggesterVocabulary() | async))" <ds-dynamic-onebox *ngIf="mdValue.editing && (((isHierarchicalVocabulary() | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary() | async))"
[group]="group" [group]="group"
[model]="getModel()" [model]="getModel()"
(change)="onChangeAuthorityField($event)"> (change)="onChangeAuthorityField($event)">
</ds-dynamic-onebox> </ds-dynamic-onebox>
<button class="btn btn-secondary mt-2" *ngIf="mdValue.editing && ((isScrollableVocabulary() | async) || (isHierarchicalVocabulary() | async))"
[title]="enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing' | translate"
(click)="toggleFreeTextEdition()">
<i class="fas fa-fw" [ngClass]="enabledFreeTextEditing ? 'fa-lock' : 'fa-unlock'"></i>
{{ (enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing') | translate }}
</button>
<div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE"> <div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE">
<span class="badge badge-light border" > <span class="badge badge-light border" >
<i dsAuthorityConfidenceState <i dsAuthorityConfidenceState

View File

@@ -191,6 +191,12 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
*/ */
public editingAuthority = false; public editingAuthority = false;
/**
* Whether or not the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used
*/
public enabledFreeTextEditing = false;
/** /**
* Field group used by authority field * Field group used by authority field
* @type {UntypedFormGroup} * @type {UntypedFormGroup}
@@ -438,15 +444,23 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
* Process the change of authority field value updating the authority key and confidence as necessary * Process the change of authority field value updating the authority key and confidence as necessary
*/ */
onChangeAuthorityField(event): void { onChangeAuthorityField(event): void {
this.mdValue.newValue.value = event.value; if (event) {
if (event.authority) { this.mdValue.newValue.value = event.value;
this.mdValue.newValue.authority = event.authority; if (event.authority) {
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; this.mdValue.newValue.authority = event.authority;
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
} else {
this.mdValue.newValue.authority = null;
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
}
this.confirm.emit(false);
} else { } else {
// The event is undefined when the user clears the selection in scrollable dropdown
this.mdValue.newValue.value = '';
this.mdValue.newValue.authority = null; this.mdValue.newValue.authority = null;
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
this.confirm.emit(false);
} }
this.confirm.emit(false);
} }
/** /**
@@ -480,4 +494,17 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
} }
} }
/**
* Toggles the free-text ediitng mode
*/
toggleFreeTextEdition() {
if (this.enabledFreeTextEditing) {
if (this.getModel().value !== this.mdValue.newValue.value) {
// Reload the model to adapt it to the new possible value modified during free text editing
this.initAuthorityProperties();
}
}
this.enabledFreeTextEditing = !this.enabledFreeTextEditing;
}
} }

View File

@@ -6,13 +6,30 @@
[formControl]="input" [formControl]="input"
(focusin)="query$.next(mdField)" (focusin)="query$.next(mdField)"
(dsClickOutside)="query$.next(null)" (dsClickOutside)="query$.next(null)"
(click)="$event.stopPropagation();" /> (click)="$event.stopPropagation();"
(keyup)="this.selectedValueLoading = false"
/>
<div class="invalid-feedback show-feedback" *ngIf="showInvalid">{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}</div> <div class="invalid-feedback show-feedback" *ngIf="showInvalid">{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}</div>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}"> <div id="scrollable-metadata-field-selector" class="dropdown-menu scrollable-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}">
<div class="dropdown-list"> <div class="dropdown-list">
<div *ngFor="let mdFieldOption of (mdFieldOptions$ | async)"> <div
<button class="d-block dropdown-item" (click)="select(mdFieldOption)"> infiniteScroll
<span [innerHTML]="mdFieldOption"></span> [infiniteScrollDistance]="1"
[infiniteScrollThrottle]="0"
[infiniteScrollContainer]="'#scrollable-metadata-field-selector'"
[fromRoot]="true"
(scrolled)="onScrollDown()">
<ng-container *ngIf="mdFieldOptions$ | async">
<button *ngFor="let listEntry of (mdFieldOptions$ | async)"
class="d-block dropdown-item"
dsHoverClass="ds-hover"
(click)="select(listEntry)" #listEntryElement>
<span [innerHTML]="listEntry"></span>
</button>
</ng-container>
<button *ngIf="loading"
class="list-group-item list-group-item-action border-0 list-entry">
<ds-loading [showMessage]="false"></ds-loading>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,5 @@
.scrollable-menu {
height: auto;
max-height: var(--ds-dso-selector-list-max-height);
overflow: scroll;
}

View File

@@ -39,7 +39,8 @@ describe('MetadataFieldSelectorComponent', () => {
metadataSchema = Object.assign(new MetadataSchema(), { metadataSchema = Object.assign(new MetadataSchema(), {
id: 0, id: 0,
prefix: 'dc', prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/', namespace: 'https://schema.org/CreativeWork',
field: '.',
}); });
metadataFields = [ metadataFields = [
Object.assign(new MetadataField(), { Object.assign(new MetadataField(), {
@@ -78,10 +79,10 @@ describe('MetadataFieldSelectorComponent', () => {
}); });
describe('when a query is entered', () => { describe('when a query is entered', () => {
const query = 'test query'; const query = 'dc.d';
beforeEach(() => { beforeEach(() => {
component.showInvalid = true; component.showInvalid = false;
component.query$.next(query); component.query$.next(query);
}); });
@@ -90,7 +91,7 @@ describe('MetadataFieldSelectorComponent', () => {
}); });
it('should query the registry service for metadata fields and include the schema', () => { it('should query the registry service for metadata fields and include the schema', () => {
expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, { elementsPerPage: 10, sort: new SortOptions('fieldName', SortDirection.ASC) }, true, false, followLink('schema')); expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, { elementsPerPage: 20, sort: new SortOptions('fieldName', SortDirection.ASC), currentPage: 1 }, true, false, followLink('schema'));
}); });
}); });

View File

@@ -24,16 +24,18 @@ import {
TranslateModule, TranslateModule,
TranslateService, TranslateService,
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest as observableCombineLatest,
Observable, Observable,
of, of,
Subscription, Subscription,
} from 'rxjs'; } from 'rxjs';
import { import {
debounceTime, debounceTime,
distinctUntilChanged,
map, map,
startWith,
switchMap, switchMap,
take, take,
tap, tap,
@@ -50,6 +52,7 @@ import {
metadataFieldsToString, metadataFieldsToString,
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ClickOutsideDirective } from '../../../shared/utils/click-outside.directive'; import { ClickOutsideDirective } from '../../../shared/utils/click-outside.directive';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
@@ -59,7 +62,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
styleUrls: ['./metadata-field-selector.component.scss'], styleUrls: ['./metadata-field-selector.component.scss'],
templateUrl: './metadata-field-selector.component.html', templateUrl: './metadata-field-selector.component.html',
standalone: true, standalone: true,
imports: [FormsModule, NgClass, ReactiveFormsModule, ClickOutsideDirective, NgIf, NgFor, AsyncPipe, TranslateModule], imports: [FormsModule, NgClass, ReactiveFormsModule, ClickOutsideDirective, NgIf, NgFor, AsyncPipe, TranslateModule, ThemedLoadingComponent, InfiniteScrollModule],
}) })
/** /**
* Component displaying a searchable input for metadata-fields * Component displaying a searchable input for metadata-fields
@@ -96,7 +99,7 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
* List of available metadata field options to choose from, dependent on the current query the user entered * List of available metadata field options to choose from, dependent on the current query the user entered
* Shows up in a dropdown below the input * Shows up in a dropdown below the input
*/ */
mdFieldOptions$: Observable<string[]>; mdFieldOptions$: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
/** /**
* FormControl for the input * FormControl for the input
@@ -131,6 +134,30 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
*/ */
subs: Subscription[] = []; subs: Subscription[] = [];
/**
* The current page to load
* Dynamically goes up as the user scrolls down until it reaches the last page possible
*/
currentPage$ = new BehaviorSubject(1);
/**
* Whether or not the list contains a next page to load
* This allows us to avoid next pages from trying to load when there are none
*/
hasNextPage = false;
/**
* Whether or not new results are currently loading
*/
loading = false;
/**
* Default page option for this feature
*/
pageOptions = { elementsPerPage: 20, sort: new SortOptions('fieldName', SortDirection.ASC) };
constructor(protected registryService: RegistryService, constructor(protected registryService: RegistryService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected translate: TranslateService) { protected translate: TranslateService) {
@@ -141,32 +168,33 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
* Update the mdFieldOptions$ depending on the query$ fired by querying the server * Update the mdFieldOptions$ depending on the query$ fired by querying the server
*/ */
ngOnInit(): void { ngOnInit(): void {
this.subs.push(this.input.valueChanges.pipe(
debounceTime(this.debounceTime),
startWith(''),
).subscribe((valueChange) => {
this.currentPage$.next(1);
if (!this.selectedValueLoading) {
this.query$.next(valueChange);
}
this.mdField = valueChange;
this.mdFieldChange.emit(this.mdField);
}));
this.subs.push( this.subs.push(
this.input.valueChanges.pipe( observableCombineLatest(
debounceTime(this.debounceTime), this.query$,
).subscribe((valueChange) => { this.currentPage$,
if (!this.selectedValueLoading) { )
this.query$.next(valueChange); .pipe(
} switchMap(([query, page]: [string, number]) => {
this.selectedValueLoading = false; this.loading = true;
this.mdField = valueChange; if (page === 1) {
this.mdFieldChange.emit(this.mdField); this.mdFieldOptions$.next([]);
}), }
); return this.search(query as string, page as number);
this.mdFieldOptions$ = this.query$.pipe( }),
distinctUntilChanged(), ).subscribe((rd ) => {
switchMap((query: string) => { if (!this.selectedValueLoading) {this.updateList(rd);}
this.showInvalid = false; }));
if (query !== null) {
return this.registryService.queryMetadataFields(query, { elementsPerPage: 10, sort: new SortOptions('fieldName', SortDirection.ASC) }, true, false, followLink('schema')).pipe(
getAllSucceededRemoteData(),
metadataFieldsToString(),
);
} else {
return [[]];
}
}),
);
} }
/** /**
@@ -210,6 +238,41 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV
this.input.setValue(mdFieldOption); this.input.setValue(mdFieldOption);
} }
/**
* When the user reaches the bottom of the page (or almost) and there's a next page available, increase the current page
*/
onScrollDown() {
if (this.hasNextPage && !this.loading) {
this.currentPage$.next(this.currentPage$.value + 1);
}
}
/**
* @Description It update the mdFieldOptions$ according the query result page
* */
updateList(list: string[]) {
this.loading = false;
this.hasNextPage = list.length > 0;
const currentEntries = this.mdFieldOptions$.getValue();
this.mdFieldOptions$.next([...currentEntries, ...list]);
this.selectedValueLoading = false;
}
/**
* Perform a search for the current query and page
* @param query Query to search objects for
* @param page Page to retrieve
* @param useCache Whether or not to use the cache
*/
search(query: string, page: number, useCache: boolean = true) {
return this.registryService.queryMetadataFields(query,{
elementsPerPage: this.pageOptions.elementsPerPage, sort: this.pageOptions.sort,
currentPage: page }, useCache, false, followLink('schema'))
.pipe(
getAllSucceededRemoteData(),
metadataFieldsToString(),
);
}
/** /**
* Unsubscribe from any open subscriptions * Unsubscribe from any open subscriptions
*/ */

View File

@@ -22,21 +22,21 @@
class="lead item-list-title dont-break-out" class="lead item-list-title dont-break-out"
[innerHTML]="dsoTitle"></span> [innerHTML]="dsoTitle"></span>
<span class="text-muted"> <span class="text-muted">
<ds-truncatable-part [id]="dso.id" [minLines]="1"> <ds-truncatable-part [id]="dso.id" [minLines]="1">
<span *ngIf="dso.allMetadata(['publicationvolume.volumeNumber']).length > 0" <span *ngIf="dso.allMetadata(['publicationvolume.volumeNumber']).length > 0"
class="item-list-journal-issues"> class="item-list-journal-issues">
<span *ngFor="let value of allMetadataValues(['publicationvolume.volumeNumber']); let last=last;"> <span *ngFor="let value of allMetadataValues(['publicationvolume.volumeNumber']); let last=last;">
<span [innerHTML]="value"><span [innerHTML]="value"></span></span> <span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span>
<span *ngIf="dso.allMetadata(['publicationissue.issueNumber']).length > 0"
class="item-list-journal-issue-numbers">
<span *ngFor="let value of allMetadataValues(['publicationissue.issueNumber']); let last=last;">
<span> - </span><span [innerHTML]="value"><span [innerHTML]="value"></span></span>
</span>
</span>
</span> </span>
<span *ngIf="dso.allMetadata(['publicationissue.issueNumber']).length > 0"
class="item-list-journal-issue-numbers">
<span *ngFor="let value of allMetadataValues(['publicationissue.issueNumber']); let first=first; let last=last;">
<span *ngIf="first"> - </span><span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span>
</span>
</span>
</ds-truncatable-part> </ds-truncatable-part>
</span> </span>
</ds-truncatable> </ds-truncatable>
</div> </div>
</div> </div>

View File

@@ -26,13 +26,13 @@
<span *ngIf="dso.allMetadata(['journal.title']).length > 0" <span *ngIf="dso.allMetadata(['journal.title']).length > 0"
class="item-list-journal-volumes"> class="item-list-journal-volumes">
<span *ngFor="let value of allMetadataValues(['journal.title']); let last=last;"> <span *ngFor="let value of allMetadataValues(['journal.title']); let last=last;">
<span [innerHTML]="value"><span [innerHTML]="value"></span></span> <span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span> </span>
</span> </span>
<span *ngIf="dso.allMetadata(['publicationvolume.volumeNumber']).length > 0" <span *ngIf="dso.allMetadata(['publicationvolume.volumeNumber']).length > 0"
class="item-list-journal-volume-identifiers"> class="item-list-journal-volume-identifiers">
<span *ngFor="let value of allMetadataValues(['publicationvolume.volumeNumber']); let last=last;"> <span *ngFor="let value of allMetadataValues(['publicationvolume.volumeNumber']); let last=last;">
<span> (</span><span [innerHTML]="value"><span [innerHTML]="value"></span></span><span>)</span> <span> (</span><span [innerHTML]="value"></span><span>)</span><span *ngIf="!last">;</span>
</span> </span>
</span> </span>
</ds-truncatable-part> </ds-truncatable-part>

View File

@@ -24,7 +24,7 @@
<span *ngIf="dso.allMetadata(['creativeworkseries.issn']).length > 0" <span *ngIf="dso.allMetadata(['creativeworkseries.issn']).length > 0"
class="item-list-journals"> class="item-list-journals">
<span *ngFor="let value of allMetadataValues(['creativeworkseries.issn']); let last=last;"> <span *ngFor="let value of allMetadataValues(['creativeworkseries.issn']); let last=last;">
<span [innerHTML]="value"><span [innerHTML]="value"></span></span> <span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span> </span>
</span> </span>
</ds-truncatable-part> </ds-truncatable-part>

View File

@@ -32,7 +32,7 @@
<!--<span *ngIf="dso.allMetadata(['project.identifier.status']).length > 0"--> <!--<span *ngIf="dso.allMetadata(['project.identifier.status']).length > 0"-->
<!--class="item-list-status">--> <!--class="item-list-status">-->
<!--<span *ngFor="let value of allMetadataValues(['project.identifier.status']); let last=last;">--> <!--<span *ngFor="let value of allMetadataValues(['project.identifier.status']); let last=last;">-->
<!--<span [innerHTML]="value"><span [innerHTML]="value"></span></span>--> <!--<span [innerHTML]="value"></span><span *ngIf="!last">; </span>-->
<!--</span>--> <!--</span>-->
<!--</span>--> <!--</span>-->
<!--</ds-truncatable-part>--> <!--</ds-truncatable-part>-->

View File

@@ -3,7 +3,7 @@
<span *ngIf="mdRepresentation.allMetadata(['person.jobTitle']).length > 0" <span *ngIf="mdRepresentation.allMetadata(['person.jobTitle']).length > 0"
class="item-list-job-title"> class="item-list-job-title">
<span *ngFor="let value of mdRepresentation.allMetadataValues(['person.jobTitle']); let last=last;"> <span *ngFor="let value of mdRepresentation.allMetadataValues(['person.jobTitle']); let last=last;">
<span [innerHTML]="value"><span [innerHTML]="value"></span></span> <span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span> </span>
</span> </span>
</span> </span>
@@ -12,4 +12,9 @@
<a [routerLink]="[itemPageRoute]" <a [routerLink]="[itemPageRoute]"
[innerHTML]="mdRepresentation.getValue()" [innerHTML]="mdRepresentation.getValue()"
[ngbTooltip]="mdRepresentation.allMetadata(['person.jobTitle']).length > 0 ? descTemplate : null"></a> [ngbTooltip]="mdRepresentation.allMetadata(['person.jobTitle']).length > 0 ? descTemplate : null"></a>
<ds-orcid-badge-and-tooltip class="ml-1"
*ngIf="mdRepresentation.firstMetadata('person.identifier.orcid')"
[orcid]="mdRepresentation.firstMetadata('person.identifier.orcid')"
[authenticatedTimestamp]="mdRepresentation.firstMetadata('dspace.orcid.authenticated')">
</ds-orcid-badge-and-tooltip>
</ds-truncatable> </ds-truncatable>

View File

@@ -7,13 +7,14 @@ import { RouterLink } from '@angular/router';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ItemMetadataRepresentationListElementComponent } from '../../../../shared/object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; import { ItemMetadataRepresentationListElementComponent } from '../../../../shared/object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component';
import { OrcidBadgeAndTooltipComponent } from '../../../../shared/orcid-badge-and-tooltip/orcid-badge-and-tooltip.component';
import { TruncatableComponent } from '../../../../shared/truncatable/truncatable.component'; import { TruncatableComponent } from '../../../../shared/truncatable/truncatable.component';
@Component({ @Component({
selector: 'ds-person-item-metadata-list-element', selector: 'ds-person-item-metadata-list-element',
templateUrl: './person-item-metadata-list-element.component.html', templateUrl: './person-item-metadata-list-element.component.html',
standalone: true, standalone: true,
imports: [NgIf, NgFor, TruncatableComponent, RouterLink, NgbTooltipModule], imports: [NgIf, NgFor, TruncatableComponent, RouterLink, NgbTooltipModule, OrcidBadgeAndTooltipComponent],
}) })
/** /**
* The component for displaying an item of the type Person as a metadata field * The component for displaying an item of the type Person as a metadata field

View File

@@ -11,11 +11,11 @@
<span class="text-muted"> <span class="text-muted">
<span *ngIf="dso.allMetadata('organization.address.addressLocality').length > 0" <span *ngIf="dso.allMetadata('organization.address.addressLocality').length > 0"
class="item-list-address-locality"> class="item-list-address-locality">
<span [innerHTML]="firstMetadataValue(['organization.address.addressLocality'])"><span [innerHTML]="firstMetadataValue(['organization.address.addressLocality'])"></span></span><span *ngIf="dso.allMetadata('organization.address.addressCountry').length > 0">, </span> <span [innerHTML]="firstMetadataValue(['organization.address.addressLocality'])"></span><span *ngIf="dso.allMetadata('organization.address.addressCountry').length > 0">, </span>
</span> </span>
<span *ngIf="dso.allMetadata('organization.address.addressCountry').length > 0" <span *ngIf="dso.allMetadata('organization.address.addressCountry').length > 0"
class="item-list-address-country"> class="item-list-address-country">
<span [innerHTML]="firstMetadataValue(['organization.address.addressCountry'])"><span [innerHTML]="firstMetadataValue(['organization.address.addressCountry'])"></span></span> <span [innerHTML]="firstMetadataValue(['organization.address.addressCountry'])"></span>
</span> </span>
</span> </span>
</div> </div>

View File

@@ -33,7 +33,7 @@ import { OrgUnitInputSuggestionsComponent } from './org-unit-suggestions/org-uni
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal) @listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({ @Component({
selector: 'ds-person-search-result-list-submission-element', selector: 'ds-org-unit-search-result-list-submission-element',
styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'], styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'],
templateUrl: './org-unit-search-result-list-submission-element.component.html', templateUrl: './org-unit-search-result-list-submission-element.component.html',
standalone: true, standalone: true,

View File

@@ -23,13 +23,13 @@
(clickSuggestion)="select($event)" (clickSuggestion)="select($event)"
(submitSuggestion)="selectCustom($event)"></ds-person-input-suggestions> (submitSuggestion)="selectCustom($event)"></ds-person-input-suggestions>
<span class="text-muted"> <span class="text-muted">
<span *ngIf="dso.allMetadata(['person.jobTitle']).length > 0" <span *ngIf="dso.allMetadata(['person.jobTitle']).length > 0"
class="item-list-job-title"> class="item-list-job-title">
<span *ngFor="let value of allMetadataValues(['person.jobTitle']); let last=last;"> <span *ngFor="let value of allMetadataValues(['person.jobTitle']); let last=last;">
<span [innerHTML]="value"><span [innerHTML]="value"></span></span> <span [innerHTML]="value"></span><span *ngIf="!last">; </span>
</span> </span>
</span>
</span> </span>
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -23,7 +23,7 @@ import {
import { NotifyInfoService } from '../core/coar-notify/notify-info/notify-info.service'; import { NotifyInfoService } from '../core/coar-notify/notify-info/notify-info.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { KlaroService } from '../shared/cookies/klaro.service'; import { OrejimeService } from '../shared/cookies/orejime.service';
import { hasValue } from '../shared/empty.util'; import { hasValue } from '../shared/empty.util';
@Component({ @Component({
@@ -46,7 +46,7 @@ export class FooterComponent implements OnInit {
coarLdnEnabled$: Observable<boolean>; coarLdnEnabled$: Observable<boolean>;
constructor( constructor(
@Optional() public cookies: KlaroService, @Optional() public cookies: OrejimeService,
protected authorizationService: AuthorizationDataService, protected authorizationService: AuthorizationDataService,
protected notifyInfoService: NotifyInfoService, protected notifyInfoService: NotifyInfoService,
@Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(APP_CONFIG) protected appConfig: AppConfig,

View File

@@ -8,7 +8,7 @@
<span class="align-self-center">{{'item.alerts.withdrawn' | translate}}</span> <span class="align-self-center">{{'item.alerts.withdrawn' | translate}}</span>
<div class="gap-2 d-flex"> <div class="gap-2 d-flex">
<a routerLink="/home" class="btn btn-primary btn-sm">{{"404.link.home-page" | translate}}</a> <a routerLink="/home" class="btn btn-primary btn-sm">{{"404.link.home-page" | translate}}</a>
<a *ngIf="showReinstateButton$() | async" class="btn btn-primary btn-sm" (click)="openReinstateModal()">{{ 'item.alerts.reinstate-request' | translate}}</a> <a *ngIf="showReinstateButton$ | async" class="btn btn-primary btn-sm" (click)="openReinstateModal()">{{ 'item.alerts.reinstate-request' | translate}}</a>
</div> </div>
</div> </div>
</ds-alert> </ds-alert>

View File

@@ -161,7 +161,7 @@ describe('ItemAlertsComponent', () => {
(authorizationService.isAuthorized).and.returnValue(isAdmin$); (authorizationService.isAuthorized).and.returnValue(isAdmin$);
(correctionTypeDataService.findByItem).and.returnValue(correction$); (correctionTypeDataService.findByItem).and.returnValue(correction$);
expectObservable(component.showReinstateButton$()).toBe(expectedMarble, expectedValues); expectObservable(component.shouldShowReinstateButton()).toBe(expectedMarble, expectedValues);
}); });
}); });

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