diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f2a84ce8a..20791be6f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,8 @@ name: Build on: [push, pull_request] permissions: - contents: read # to fetch code (actions/checkout) + contents: read # to fetch code (actions/checkout) + packages: read # to fetch private images from GitHub Container Registry (GHCR) jobs: tests: @@ -35,6 +36,9 @@ jobs: NODE_OPTIONS: '--max-old-space-size=4096' # Project name to use when running "docker compose" prior to e2e tests COMPOSE_PROJECT_NAME: 'ci' + # Docker Registry to use for Docker compose scripts below. + # We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub. + DOCKER_REGISTRY: ghcr.io strategy: # Create a matrix of Node versions to test against (in parallel) matrix: @@ -114,6 +118,14 @@ jobs: path: 'coverage/dspace-angular/lcov.info' retention-days: 14 + # Login to our Docker registry, so that we can access private Docker images using "docker compose" below. + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + # Using "docker compose" start backend using CI configuration # and load assetstore from a cached copy - name: Start DSpace REST Backend via Docker (for e2e tests) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d0b4cd0939..bae8c01300 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,7 +16,8 @@ on: pull_request: permissions: - contents: read # to fetch code (actions/checkout) + contents: read # to fetch code (actions/checkout) + packages: write # to write images to GitHub Container Registry (GHCR) jobs: ############################################################# diff --git a/Dockerfile b/Dockerfile index 8fac7495e1..e395e4b90e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # This image will be published as dspace/dspace-angular # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details -FROM node:18-alpine +FROM docker.io/node:18-alpine # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 @@ -24,5 +24,5 @@ ENV NODE_OPTIONS="--max_old_space_size=4096" # Listen / accept connections from all IP addresses. # NOTE: At this time it is only possible to run Docker container in Production mode # if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485 -ENV NODE_ENV development +ENV NODE_ENV=development CMD yarn serve --host 0.0.0.0 diff --git a/Dockerfile.dist b/Dockerfile.dist index 4c47b0cb40..be72de4afc 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -2,9 +2,9 @@ # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details # Test build: -# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist . +# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-8_x-dist . -FROM node:18-alpine AS build +FROM docker.io/node:18-alpine AS build # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 @@ -26,6 +26,6 @@ COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json WORKDIR /app USER node -ENV NODE_ENV production +ENV NODE_ENV=production EXPOSE 4000 CMD pm2-runtime start dspace-ui.json --json diff --git a/README.md b/README.md index ebc24f8b91..fe2af85aa4 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v16.x` or `v18.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v18.x` or `v20.x`, [npm](https://www.npmjs.com/) >= `v10.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v16.x` or `v18.x` and yarn == `v1.x` +- Ensure you're running node `v18.x` or `v20.x` and yarn == `v1.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. diff --git a/config/config.example.yml b/config/config.example.yml index ebe1b2ff9b..d1a40e6b1f 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,7 +1,7 @@ # NOTE: will log all redux actions and transfers in console debug: false -# Angular Universal server settings +# Angular User Inteface settings # NOTE: these settings define where Node.js will start your UI application. Therefore, these # "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar) ui: @@ -17,12 +17,12 @@ ui: # Trust X-FORWARDED-* headers from proxies (default = true) useProxies: true -universal: - # Whether to inline "critical" styles into the server-side rendered HTML. - # Determining which styles are critical is a relatively expensive operation; - # this option can be disabled to boost server performance at the expense of - # loading smoothness. - inlineCriticalCss: true +# Angular Server Side Rendering (SSR) settings +ssr: + # Whether to tell Angular to inline "critical" styles into the server-side rendered HTML. + # Determining which styles are critical is a relatively expensive operation; this option is + # disabled (false) by default to boost server performance at the expense of loading smoothness. + inlineCriticalCss: false # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually diff --git a/cypress/e2e/admin-add-new-modals.cy.ts b/cypress/e2e/admin-add-new-modals.cy.ts index 565ae154f1..332d44da13 100644 --- a/cypress/e2e/admin-add-new-modals.cy.ts +++ b/cypress/e2e/admin-add-new-modals.cy.ts @@ -9,9 +9,11 @@ describe('Admin Add New Modals', () => { it('Add new Community modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); cy.get('#admin-menu-section-new-title').click(); cy.get('a[data-test="menu.section.new_community"]').click(); @@ -22,9 +24,11 @@ describe('Admin Add New Modals', () => { it('Add new Collection modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); cy.get('#admin-menu-section-new-title').click(); cy.get('a[data-test="menu.section.new_collection"]').click(); @@ -35,9 +39,11 @@ describe('Admin Add New Modals', () => { it('Add new Item modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-new-title').should('be.visible'); cy.get('#admin-menu-section-new-title').click(); cy.get('a[data-test="menu.section.new_item"]').click(); diff --git a/cypress/e2e/admin-edit-modals.cy.ts b/cypress/e2e/admin-edit-modals.cy.ts index e96d6ce898..8ba524d5be 100644 --- a/cypress/e2e/admin-edit-modals.cy.ts +++ b/cypress/e2e/admin-edit-modals.cy.ts @@ -9,9 +9,11 @@ describe('Admin Edit Modals', () => { it('Edit Community modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); cy.get('#admin-menu-section-edit-title').click(); cy.get('a[data-test="menu.section.edit_community"]').click(); @@ -22,9 +24,11 @@ describe('Admin Edit Modals', () => { it('Edit Collection modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); cy.get('#admin-menu-section-edit-title').click(); cy.get('a[data-test="menu.section.edit_collection"]').click(); @@ -35,9 +39,11 @@ describe('Admin Edit Modals', () => { it('Edit Item modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-edit-title').should('be.visible'); cy.get('#admin-menu-section-edit-title').click(); cy.get('a[data-test="menu.section.edit_item"]').click(); diff --git a/cypress/e2e/admin-export-modals.cy.ts b/cypress/e2e/admin-export-modals.cy.ts index 9f69764d19..884db4ed33 100644 --- a/cypress/e2e/admin-export-modals.cy.ts +++ b/cypress/e2e/admin-export-modals.cy.ts @@ -9,9 +9,11 @@ describe('Admin Export Modals', () => { it('Export metadata modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-export-title').should('be.visible'); cy.get('#admin-menu-section-export-title').click(); cy.get('a[data-test="menu.section.export_metadata"]').click(); @@ -22,9 +24,11 @@ describe('Admin Export Modals', () => { it('Export batch modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu + cy.get('#admin-menu-section-export-title').should('be.visible'); cy.get('#admin-menu-section-export-title').click(); cy.get('a[data-test="menu.section.export_batch"]').click(); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts index 45131baace..ad5d8ea093 100644 --- a/cypress/e2e/item-edit.cy.ts +++ b/cypress/e2e/item-edit.cy.ts @@ -9,18 +9,15 @@ beforeEach(() => { // 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')); - - // 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', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="metadata"]').should('be.visible'); cy.get('a[data-test="metadata"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -39,9 +36,11 @@ describe('Edit Item > Edit Metadata tab', () => { describe('Edit Item > Status tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="status"]').should('be.visible'); cy.get('a[data-test="status"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -55,9 +54,11 @@ describe('Edit Item > Status tab', () => { describe('Edit Item > Bitstreams tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="bitstreams"]').should('be.visible'); cy.get('a[data-test="bitstreams"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -82,9 +83,11 @@ describe('Edit Item > Bitstreams tab', () => { describe('Edit Item > Curate tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').should('be.visible'); cy.get('a[data-test="curate"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -98,9 +101,11 @@ describe('Edit Item > Curate tab', () => { describe('Edit Item > Relationships tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="relationships"]').should('be.visible'); cy.get('a[data-test="relationships"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -114,9 +119,11 @@ describe('Edit Item > Relationships tab', () => { describe('Edit Item > Version History tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="versionhistory"]').should('be.visible'); cy.get('a[data-test="versionhistory"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -130,9 +137,11 @@ describe('Edit Item > Version History tab', () => { describe('Edit Item > Access Control tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').should('be.visible'); cy.get('a[data-test="access-control"]').click(); - // Our selected tab should be 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'); // tag must be loaded @@ -146,9 +155,11 @@ describe('Edit Item > Access Control tab', () => { describe('Edit Item > Collection Mapper tab', () => { it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').should('be.visible'); cy.get('a[data-test="mapper"]').click(); - // Our selected tab should be 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'); // tag must be loaded diff --git a/docker/README.md b/docker/README.md index 3dc5fd5055..6360124b60 100644 --- a/docker/README.md +++ b/docker/README.md @@ -23,14 +23,14 @@ the Docker compose scripts in this 'docker' folder. This Dockerfile is used to build a *development* DSpace Angular UI image, published as 'dspace/dspace-angular' ``` -docker build -t dspace/dspace-angular:latest . +docker build -t dspace/dspace-angular:dspace-8_x . ``` This image is built *automatically* after each commit is made to the `main` branch. Admins to our DockerHub repo can manually publish with the following command. ``` -docker push dspace/dspace-angular:latest +docker push dspace/dspace-angular:dspace-8_x ``` ### Dockerfile.dist @@ -39,7 +39,7 @@ The `Dockerfile.dist` is used to generate a *production* build and runtime envir ```bash # build the latest image -docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist . +docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-8_x-dist . ``` A default/demo version of this image is built *automatically*. diff --git a/docker/cli.yml b/docker/cli.yml index 9b1973426f..7c17b14b1b 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -14,14 +14,14 @@ # Therefore, it should be kept in sync with that file networks: # Default to using network named 'dspacenet' from docker-compose-rest.yml. - # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") + # Its full name will be prepended with the project name (e.g. "-p d8" means it will be named "d8_dspacenet") # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) default: name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet external: true services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-8_x}" container_name: dspace-cli environment: # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. diff --git a/docker/db.entities.yml b/docker/db.entities.yml index b3cf5bd86f..464253f07b 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -14,10 +14,11 @@ # # Therefore, it should be kept in sync with that file services: dspacedb: - image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}-loadsql" environment: # This LOADSQL should be kept in sync with the URL in DSpace/DSpace # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + # NOTE: currently there is no dspace8 version - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index c5c419a4a7..d2589bb3f3 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -33,7 +33,7 @@ services: # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' LOGGING_CONFIG: /dspace/config/log4j2-container.xml - image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb networks: @@ -60,11 +60,12 @@ services: # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data dspacedb: container_name: dspacedb - image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}-loadsql" environment: # This LOADSQL should be kept in sync with the LOADSQL in # https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data + # NOTE: currently there is no dspace8 version LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql PGDATA: /pgdata POSTGRES_PASSWORD: dspace @@ -81,7 +82,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-8_x}" networks: - dspacenet ports: diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 67eba16785..03e5e9da70 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -26,7 +26,7 @@ services: DSPACE_REST_HOST: sandbox.dspace.org DSPACE_REST_PORT: 443 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}-dist" build: context: .. dockerfile: Dockerfile.dist diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index 09dfcf2a5f..37a5d23e77 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -40,7 +40,7 @@ services: # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' LOGGING_CONFIG: /dspace/config/log4j2-container.xml - image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-8_x-test}" depends_on: - dspacedb networks: @@ -68,7 +68,7 @@ services: dspacedb: container_name: dspacedb # Uses a custom Postgres image with pgcrypto installed - image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-8_x}" environment: PGDATA: /pgdata POSTGRES_PASSWORD: dspace @@ -85,7 +85,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-8_x}" networks: - dspacenet ports: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1c268b84b7..8e85520f9f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -23,7 +23,7 @@ services: DSPACE_REST_HOST: localhost DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:${DSPACE_VER:-latest} + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-8_x}" build: context: .. dockerfile: Dockerfile diff --git a/package.json b/package.json index a430cd6640..f1fb80645d 100644 --- a/package.json +++ b/package.json @@ -78,19 +78,19 @@ "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "angulartics2": "^12.2.0", - "axios": "^1.7.4", + "axios": "^1.7.9", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", "colors": "^1.4.0", - "compression": "^1.7.4", + "compression": "^1.7.5", "cookie-parser": "1.4.7", - "core-js": "^3.30.1", + "core-js": "^3.39.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", "ejs": "^3.1.10", - "express": "^4.21.1", + "express": "^4.21.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", @@ -100,13 +100,13 @@ "js-cookie": "2.2.1", "js-yaml": "^4.1.0", "json5": "^2.2.3", - "jsonschema": "1.4.1", + "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", "klaro": "^0.7.18", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", - "mirador": "^3.3.0", + "mirador": "^3.4.2", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", "morgan": "^1.10.0", @@ -135,7 +135,7 @@ "@angular/compiler-cli": "^17.3.11", "@angular/language-service": "^17.3.11", "@cypress/schematic": "^1.5.0", - "@fortawesome/fontawesome-free": "^6.4.0", + "@fortawesome/fontawesome-free": "^6.7.2", "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@ngrx/store-devtools": "^17.1.1", @@ -146,7 +146,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.13", + "@types/lodash": "^4.17.14", "@types/node": "^14.14.9", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -157,7 +157,7 @@ "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", "csstype": "^3.1.3", - "cypress": "^13.15.1", + "cypress": "^13.17.0", "cypress-axe": "^1.5.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", @@ -167,12 +167,12 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.6.0", + "eslint-plugin-jsonc": "^2.18.2", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-unused-imports": "^3.2.0", - "express-static-gzip": "^2.1.8", + "express-static-gzip": "^2.2.0", "jasmine": "^3.8.0", "jasmine-core": "^3.8.0", "jasmine-marbles": "0.9.2", @@ -194,12 +194,12 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "sass": "~1.80.4", + "sass": "~1.83.1", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", "typescript": "~5.4.5", - "webpack": "5.95.0", + "webpack": "5.97.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" } diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index b16d8ac659..b5a26533cf 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -61,7 +61,7 @@ + [ngClass]="{'table-primary' : (activeEPerson$ | async) === epersonDto.eperson}"> {{epersonDto.eperson.id}} {{ dsoNameService.getName(epersonDto.eperson) }} {{epersonDto.eperson.email}} diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index 5466ed0152..6b62a13ecf 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -100,6 +100,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ ePeopleDto$: BehaviorSubject> = new BehaviorSubject>({} as any); + activeEPerson$: Observable; + /** * An observable for the pageInfo, needed to pass to the pagination component */ @@ -165,6 +167,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { initialisePage() { this.searching$.next(true); this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); + this.activeEPerson$ = this.epersonService.getActiveEPerson(); this.subs.push(this.ePeople$.pipe( switchMap((epeople: PaginatedList) => { 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 { - return this.getActiveEPerson().pipe( - map((activeEPerson) => eperson === activeEPerson), - ); - } - - /** - * Gets the active eperson (being edited) - */ - getActiveEPerson(): Observable { - return this.epersonService.getActiveEPerson(); - } - /** * Deletes EPerson, show notification on success/failure & updates EPeople list */ diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index b4c0781ac7..ae1046be45 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -2,7 +2,7 @@
-
+

{{messagePrefix + '.create' | translate}}

@@ -44,7 +44,7 @@ -
+

{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}

@@ -75,7 +75,9 @@ {{ dsoNameService.getName(group) }} - {{ dsoNameService.getName(undefined) }} + + {{ dsoNameService.getName((group.object | async)?.payload) }} + diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 7c03b9eab0..e61f95d6e5 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, @@ -19,12 +19,10 @@ import { import { ActivatedRoute, Router, + RouterModule, } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { - TranslateLoader, - TranslateModule, -} from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable, of as observableOf, @@ -49,7 +47,6 @@ import { FormBuilderService } from '../../../shared/form/builder/form-builder.se import { FormComponent } from '../../../shared/form/form.component'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; -import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; @@ -92,9 +89,6 @@ describe('EPersonFormComponent', () => { ePersonDataServiceStub = { activeEPerson: null, allEpeople: mockEPeople, - getEPeople(): Observable>> { - return createSuccessfulRemoteDataObject$(buildPaginatedList(null, this.allEpeople)); - }, getActiveEPerson(): Observable { return observableOf(this.activeEPerson); }, @@ -228,12 +222,8 @@ describe('EPersonFormComponent', () => { router = new RouterStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), + RouterModule.forRoot([]), + TranslateModule.forRoot(), EPersonFormComponent, HasNoValuePipe, ], @@ -251,7 +241,7 @@ describe('EPersonFormComponent', () => { { provide: Router, useValue: router }, EPeopleRegistryComponent, ], - schemas: [NO_ERRORS_SCHEMA], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .overrideComponent(EPersonFormComponent, { remove: { imports: [ ThemedLoadingComponent, PaginationComponent,FormComponent] }, @@ -274,37 +264,13 @@ describe('EPersonFormComponent', () => { }); describe('check form validation', () => { - let firstName; - let lastName; - let email; - let canLogIn; - let requireCertificate; + let canLogIn: boolean; + let requireCertificate: boolean; - let expected; beforeEach(() => { - firstName = 'testName'; - lastName = 'testLastName'; - email = 'testEmail@test.com'; canLogIn = false; requireCertificate = false; - expected = Object.assign(new EPerson(), { - metadata: { - 'eperson.firstname': [ - { - value: firstName, - }, - ], - 'eperson.lastname': [ - { - value: lastName, - }, - ], - }, - email: email, - canLogIn: canLogIn, - requireCertificate: requireCertificate, - }); spyOn(component.submitForm, 'emit'); component.canLogIn.value = canLogIn; component.requireCertificate.value = requireCertificate; @@ -378,15 +344,13 @@ describe('EPersonFormComponent', () => { expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); }); }); - - - }); + describe('when submitting the form', () => { let firstName; let lastName; let email; - let canLogIn; + let canLogIn: boolean; let requireCertificate; let expected; @@ -415,6 +379,7 @@ describe('EPersonFormComponent', () => { requireCertificate: requireCertificate, }); spyOn(component.submitForm, 'emit'); + component.ngOnInit(); component.firstName.value = firstName; component.lastName.value = lastName; component.email.value = email; @@ -454,9 +419,17 @@ describe('EPersonFormComponent', () => { email: email, canLogIn: canLogIn, requireCertificate: requireCertificate, - _links: undefined, + _links: { + groups: { + href: '', + }, + self: { + href: '', + }, + }, }); spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId)); + component.ngOnInit(); component.onSubmit(); fixture.detectChanges(); }); @@ -504,22 +477,19 @@ describe('EPersonFormComponent', () => { }); describe('delete', () => { - - let ePersonId; let eperson: EPerson; let modalService; beforeEach(() => { spyOn(authService, 'impersonate').and.callThrough(); - ePersonId = 'testEPersonId'; eperson = EPersonMock; component.epersonInitial = eperson; component.canDelete$ = observableOf(true); spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson)); modalService = (component as any).modalService; spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); + component.ngOnInit(); fixture.detectChanges(); - }); it('the delete button should be visible if the ePerson can be deleted', () => { diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 05efde7cf7..942481c4fd 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -189,6 +189,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ canImpersonate$: Observable; + /** + * The current {@link EPerson} + */ + activeEPerson$: Observable; + /** * List of subscriptions */ @@ -254,7 +259,11 @@ export class EPersonFormComponent implements OnInit, OnDestroy { protected route: ActivatedRoute, protected router: Router, ) { - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + } + + ngOnInit() { + this.activeEPerson$ = this.epersonService.getActiveEPerson(); + this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => { this.epersonInitial = eperson; if (hasValue(eperson)) { this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); @@ -262,9 +271,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.submitLabel = 'form.submit'; } })); - } - - ngOnInit() { this.initialisePage(); } @@ -272,130 +278,121 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { - this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData) => { - this.epersonService.editEPerson(ePersonRD.payload); - })); - observableCombineLatest([ - this.translateService.get(`${this.messagePrefix}.firstName`), - this.translateService.get(`${this.messagePrefix}.lastName`), - this.translateService.get(`${this.messagePrefix}.email`), - this.translateService.get(`${this.messagePrefix}.canLogIn`), - this.translateService.get(`${this.messagePrefix}.requireCertificate`), - this.translateService.get(`${this.messagePrefix}.emailHint`), - ]).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { - this.firstName = new DynamicInputModel({ - id: 'firstName', - label: firstName, - name: 'firstName', - validators: { - required: null, - }, - required: true, - }); - this.lastName = new DynamicInputModel({ - id: 'lastName', - label: lastName, - name: 'lastName', - validators: { - required: null, - }, - required: true, - }); - this.email = new DynamicInputModel({ - id: 'email', - label: email, - name: 'email', - validators: { - required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', - }, - required: true, - errorMessages: { - emailTaken: 'error.validation.emailTaken', - pattern: 'error.validation.NotValidEmail', - }, - hint: emailHint, - }); - this.canLogIn = new DynamicCheckboxModel( - { - id: 'canLogIn', - label: canLogIn, - name: 'canLogIn', - value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true), - }); - this.requireCertificate = new DynamicCheckboxModel( - { - id: 'requireCertificate', - label: requireCertificate, - name: 'requireCertificate', - value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false), - }); - this.formModel = [ - this.firstName, - this.lastName, - this.email, - this.canLogIn, - this.requireCertificate, - ]; - this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - if (eperson != null) { - this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, { - currentPage: 1, - elementsPerPage: this.config.pageSize, - }); - } - this.formGroup.patchValue({ - firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', - lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', - email: eperson != null ? eperson.email : '', - canLogIn: eperson != null ? eperson.canLogIn : true, - requireCertificate: eperson != null ? eperson.requireCertificate : false, - }); - - if (eperson === null && !!this.formGroup.controls.email) { - this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService)); - this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => { - this.changeDetectorRef.detectChanges(); - }); - } + if (this.route.snapshot.params.id) { + this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData) => { + this.epersonService.editEPerson(ePersonRD.payload); })); - - const activeEPerson$ = this.epersonService.getActiveEPerson(); - - this.groups$ = activeEPerson$.pipe( - switchMap((eperson) => { - return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { - currentPage: 1, - elementsPerPage: this.config.pageSize, - })]); - }), - switchMap(([eperson, findListOptions]) => { - if (eperson != null) { - return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); - } - return observableOf(undefined); - }), - ); - - this.groupsPageInfoState$ = this.groups$.pipe( - map(groupsRD => groupsRD.payload.pageInfo), - ); - - this.canImpersonate$ = activeEPerson$.pipe( - switchMap((eperson) => { - if (hasValue(eperson)) { - return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); - } else { - return observableOf(false); - } - }), - ); - this.canDelete$ = activeEPerson$.pipe( - switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)), - ); - this.canReset$ = observableOf(true); + } + this.firstName = new DynamicInputModel({ + id: 'firstName', + label: this.translateService.instant(`${this.messagePrefix}.firstName`), + name: 'firstName', + validators: { + required: null, + }, + required: true, }); + this.lastName = new DynamicInputModel({ + id: 'lastName', + label: this.translateService.instant(`${this.messagePrefix}.lastName`), + name: 'lastName', + validators: { + required: null, + }, + required: true, + }); + this.email = new DynamicInputModel({ + id: 'email', + label: this.translateService.instant(`${this.messagePrefix}.email`), + name: 'email', + validators: { + required: null, + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', + }, + required: true, + errorMessages: { + emailTaken: 'error.validation.emailTaken', + pattern: 'error.validation.NotValidEmail', + }, + hint: this.translateService.instant(`${this.messagePrefix}.emailHint`), + }); + this.canLogIn = new DynamicCheckboxModel( + { + id: 'canLogIn', + label: this.translateService.instant(`${this.messagePrefix}.canLogIn`), + name: 'canLogIn', + value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true), + }); + this.requireCertificate = new DynamicCheckboxModel( + { + id: 'requireCertificate', + label: this.translateService.instant(`${this.messagePrefix}.requireCertificate`), + name: 'requireCertificate', + value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false), + }); + this.formModel = [ + this.firstName, + this.lastName, + this.email, + this.canLogIn, + this.requireCertificate, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.subs.push(this.activeEPerson$.subscribe((eperson: EPerson) => { + if (eperson != null) { + this.groups$ = this.groupsDataService.findListByHref(eperson._links.groups.href, { + currentPage: 1, + elementsPerPage: this.config.pageSize, + }, undefined, undefined, followLink('object')); + } + this.formGroup.patchValue({ + firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', + lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', + email: eperson != null ? eperson.email : '', + canLogIn: eperson != null ? eperson.canLogIn : true, + requireCertificate: eperson != null ? eperson.requireCertificate : false, + }); + + if (eperson === null && !!this.formGroup.controls.email) { + this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService)); + this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); + }); + } + })); + + this.groups$ = this.activeEPerson$.pipe( + switchMap((eperson) => { + return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, { + currentPage: 1, + elementsPerPage: this.config.pageSize, + })]); + }), + switchMap(([eperson, findListOptions]) => { + if (eperson != null) { + return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); + } + return observableOf(undefined); + }), + ); + + this.groupsPageInfoState$ = this.groups$.pipe( + map(groupsRD => groupsRD.payload.pageInfo), + ); + + this.canImpersonate$ = this.activeEPerson$.pipe( + switchMap((eperson) => { + if (hasValue(eperson)) { + return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); + } else { + return observableOf(false); + } + }), + ); + this.canDelete$ = this.activeEPerson$.pipe( + switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)), + ); + this.canReset$ = observableOf(true); } /** @@ -414,7 +411,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Emit the updated/created eperson using the EventEmitter submitForm */ onSubmit() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( + this.activeEPerson$.pipe(take(1)).subscribe( (ePerson: EPerson) => { const values = { metadata: { @@ -533,7 +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. */ delete(): void { - this.epersonService.getActiveEPerson().pipe( + this.activeEPerson$.pipe( take(1), switchMap((eperson: EPerson) => { 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 */ 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); })); } diff --git a/src/app/access-control/group-registry/group-form/group-form.component.html b/src/app/access-control/group-registry/group-form/group-form.component.html index dc477352e5..7e8c1ed1b4 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.html +++ b/src/app/access-control/group-registry/group-form/group-form.component.html @@ -2,7 +2,7 @@
-
+

{{messagePrefix + '.head.create' | translate}}

@@ -23,11 +23,15 @@
- - - + + + + + + + {{messagePrefix + '.return' | translate}}
-
+
-
- -
- - - - + +
+ +
+ +
diff --git a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts index 02de06f415..b7e6a35d4e 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.spec.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, @@ -23,11 +23,7 @@ import { } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { Store } from '@ngrx/store'; -import { - TranslateLoader, - TranslateModule, - TranslateService, -} from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { Operation } from 'fast-json-patch'; import { Observable, @@ -61,15 +57,14 @@ import { FormComponent } from '../../../shared/form/form.component'; import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock'; import { RouterMock } from '../../../shared/mocks/router.mock'; -import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { GroupMock, GroupMock2, } from '../../../shared/testing/group-mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; -import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mock'; import { GroupFormComponent } from './group-form.component'; import { MembersListComponent } from './members-list/members-list.component'; import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component'; @@ -78,19 +73,19 @@ import { ValidateGroupExists } from './validators/group-exists.validator'; describe('GroupFormComponent', () => { let component: GroupFormComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; let ePersonDataServiceStub: any; let groupsDataServiceStub: any; let dsoDataServiceStub: any; let authorizationService: AuthorizationDataService; let notificationService: NotificationsServiceStub; - let router; + let router: RouterMock; + let route: ActivatedRouteStub; - let groups; - let groupName; - let groupDescription; - let expected; + let groups: Group[]; + let groupName: string; + let groupDescription: string; + let expected: Group; beforeEach(waitForAsync(() => { groups = [GroupMock, GroupMock2]; @@ -105,6 +100,15 @@ describe('GroupFormComponent', () => { }, ], }, + object: createSuccessfulRemoteDataObject$(undefined), + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); ePersonDataServiceStub = {}; groupsDataServiceStub = { @@ -141,7 +145,14 @@ describe('GroupFormComponent', () => { create(group: Group): Observable> { this.allGroups = [...this.allGroups, group]; this.createdGroup = Object.assign({}, group, { - _links: { self: { href: 'group-selflink' } }, + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); return createSuccessfulRemoteDataObject$(this.createdGroup); }, @@ -223,17 +234,15 @@ describe('GroupFormComponent', () => { return typeof value === 'object' && value !== null; }, }); - translateService = getMockTranslateService(); router = new RouterMock(); + route = new ActivatedRouteStub(); notificationService = new NotificationsServiceStub(); + return TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock, - }, - }), GroupFormComponent], + TranslateModule.forRoot(), + GroupFormComponent, + ], providers: [ { provide: DSONameService, useValue: new DSONameServiceMock() }, { provide: EPersonDataService, useValue: ePersonDataServiceStub }, @@ -249,14 +258,11 @@ describe('GroupFormComponent', () => { { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, - { - provide: ActivatedRoute, - useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) }, - }, + { provide: ActivatedRoute, useValue: route }, { provide: Router, useValue: router }, { provide: AuthorizationDataService, useValue: authorizationService }, ], - schemas: [NO_ERRORS_SCHEMA], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .overrideComponent(GroupFormComponent, { remove: { imports: [ @@ -279,8 +285,8 @@ describe('GroupFormComponent', () => { describe('when submitting the form', () => { beforeEach(() => { spyOn(component.submitForm, 'emit'); - component.groupName.value = groupName; - component.groupDescription.value = groupDescription; + component.groupName.setValue(groupName); + component.groupDescription.setValue(groupDescription); }); describe('without active Group', () => { beforeEach(() => { @@ -288,14 +294,22 @@ describe('GroupFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new group using the correct values', (async () => { - await fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); + it('should emit a new group using the correct values', (() => { + expect(component.submitForm.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + name: groupName, + metadata: { + 'dc.description': [ + { + value: groupDescription, + }, + ], + }, + })); })); }); + describe('with active Group', () => { - let expected2; + let expected2: Group; beforeEach(() => { expected2 = Object.assign(new Group(), { name: 'newGroupName', @@ -306,15 +320,24 @@ describe('GroupFormComponent', () => { }, ], }, + object: createSuccessfulRemoteDataObject$(undefined), + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf(expected)); spyOn(groupsDataServiceStub, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(expected2)); - component.groupName.value = 'newGroupName'; - component.onSubmit(); - fixture.detectChanges(); + component.ngOnInit(); }); it('should edit with name and description operations', () => { + component.groupName.setValue('newGroupName'); + component.onSubmit(); const operations = [{ op: 'add', path: '/metadata/dc.description', @@ -328,9 +351,8 @@ describe('GroupFormComponent', () => { }); it('should edit with description operations', () => { - component.groupName.value = null; + component.groupName.setValue(null); component.onSubmit(); - fixture.detectChanges(); const operations = [{ op: 'add', path: '/metadata/dc.description', @@ -340,9 +362,9 @@ describe('GroupFormComponent', () => { }); it('should edit with name operations', () => { - component.groupDescription.value = null; + component.groupName.setValue('newGroupName'); + component.groupDescription.setValue(null); component.onSubmit(); - fixture.detectChanges(); const operations = [{ op: 'replace', path: '/name', @@ -351,12 +373,13 @@ describe('GroupFormComponent', () => { expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations); }); - it('should emit the existing group using the correct new values', (async () => { - await fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); - }); - })); + it('should emit the existing group using the correct new values', () => { + component.onSubmit(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); + }); + it('should emit success notification', () => { + component.onSubmit(); expect(notificationService.success).toHaveBeenCalled(); }); }); @@ -371,11 +394,8 @@ describe('GroupFormComponent', () => { describe('check form validation', () => { - let groupCommunity; - beforeEach(() => { groupName = 'testName'; - groupCommunity = 'testgroupCommunity'; groupDescription = 'testgroupDescription'; expected = Object.assign(new Group(), { @@ -387,8 +407,17 @@ describe('GroupFormComponent', () => { }, ], }, + _links: { + self: { + href: 'group-selflink', + }, + object: { + href: 'group-objectlink', + }, + }, }); spyOn(component.submitForm, 'emit'); + spyOn(dsoDataServiceStub, 'findByHref').and.returnValue(observableOf(expected)); fixture.detectChanges(); component.initialisePage(); @@ -438,21 +467,20 @@ describe('GroupFormComponent', () => { }); describe('delete', () => { - let deleteButton; + let deleteButton: HTMLButtonElement; - beforeEach(() => { - component.initialisePage(); - - component.canEdit$ = observableOf(true); - component.groupBeingEdited = { + beforeEach(async () => { + spyOn(groupsDataServiceStub, 'delete').and.callThrough(); + component.activeGroup$ = observableOf({ + id: 'active-group', permanent: false, - } as Group; + } as Group); + component.canEdit$ = observableOf(true); + + component.initialisePage(); fixture.detectChanges(); deleteButton = fixture.debugElement.query(By.css('.delete-button')).nativeElement; - - spyOn(groupsDataServiceStub, 'delete').and.callThrough(); - spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf({ id: 'active-group' })); }); describe('if confirmed via modal', () => { diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 9fcbcbfeff..444fffb0d3 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -11,7 +11,10 @@ import { OnInit, Output, } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { + AbstractControl, + UntypedFormGroup, +} from '@angular/forms'; import { ActivatedRoute, Router, @@ -31,13 +34,10 @@ import { Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable, - of as observableOf, Subscription, } from 'rxjs'; import { - catchError, debounceTime, - filter, map, switchMap, take, @@ -53,7 +53,6 @@ import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { RequestService } from '../../../core/data/request.service'; -import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { Group } from '../../../core/eperson/models/group.model'; import { Collection } from '../../../core/shared/collection.model'; @@ -61,9 +60,9 @@ import { Community } from '../../../core/shared/community.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { NoContent } from '../../../core/shared/NoContent.model'; import { + getAllCompletedRemoteData, getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getFirstSucceededRemoteDataPayload, getRemoteDataPayload, } from '../../../core/shared/operators'; import { AlertComponent } from '../../../shared/alert/alert.component'; @@ -117,9 +116,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { /** * Dynamic models for the inputs of form */ - groupName: DynamicInputModel; - groupCommunity: DynamicInputModel; - groupDescription: DynamicTextAreaModel; + groupName: AbstractControl; + groupCommunity: AbstractControl; + groupDescription: AbstractControl; /** * A list of all dynamic input models @@ -162,21 +161,30 @@ export class GroupFormComponent implements OnInit, OnDestroy { */ subs: Subscription[] = []; - /** - * Group currently being edited - */ - groupBeingEdited: Group; - /** * Observable whether or not the logged in user is allowed to delete the Group & doesn't have a linked object (community / collection linked to workspace group */ canEdit$: Observable; /** - * The AlertType enumeration - * @type {AlertType} + * The current {@link Group} */ - public AlertTypeEnum = AlertType; + activeGroup$: Observable; + + /** + * The current {@link Group}'s linked {@link Community}/{@link Collection} + */ + activeGroupLinkedDSO$: Observable; + + /** + * Link to the current {@link Group}'s {@link Community}/{@link Collection} edit role tab + */ + linkedEditRolesRoute$: Observable; + + /** + * The AlertType enumeration + */ + public readonly AlertType = AlertType; /** * Subscription to email field value change @@ -186,126 +194,121 @@ export class GroupFormComponent implements OnInit, OnDestroy { constructor( public groupDataService: GroupDataService, - private ePersonDataService: EPersonDataService, - private dSpaceObjectDataService: DSpaceObjectDataService, - private formBuilderService: FormBuilderService, - private translateService: TranslateService, - private notificationsService: NotificationsService, - private route: ActivatedRoute, + protected dSpaceObjectDataService: DSpaceObjectDataService, + protected formBuilderService: FormBuilderService, + protected translateService: TranslateService, + protected notificationsService: NotificationsService, + protected route: ActivatedRoute, protected router: Router, - private authorizationService: AuthorizationDataService, - private modalService: NgbModal, + protected authorizationService: AuthorizationDataService, + protected modalService: NgbModal, public requestService: RequestService, protected changeDetectorRef: ChangeDetectorRef, public dsoNameService: DSONameService, ) { } - ngOnInit() { + ngOnInit(): void { + if (this.route.snapshot.params.groupId !== 'newGroup') { + this.setActiveGroup(this.route.snapshot.params.groupId); + } + this.activeGroup$ = this.groupDataService.getActiveGroup(); + this.activeGroupLinkedDSO$ = this.getActiveGroupLinkedDSO(); + this.linkedEditRolesRoute$ = this.getLinkedEditRolesRoute(); + this.canEdit$ = this.activeGroupLinkedDSO$.pipe( + switchMap((dso: DSpaceObject) => { + if (hasValue(dso)) { + return [false]; + } else { + return this.activeGroup$.pipe( + hasValueOperator(), + switchMap((group: Group) => this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self)), + ); + } + }), + ); this.initialisePage(); } initialisePage() { - this.subs.push(this.route.params.subscribe((params) => { - if (params.groupId !== 'newGroup') { - this.setActiveGroup(params.groupId); - } - })); - this.canEdit$ = this.groupDataService.getActiveGroup().pipe( - hasValueOperator(), - switchMap((group: Group) => { - return observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), - this.hasLinkedDSO(group), - ]).pipe( - map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO), - ); + const groupNameModel = new DynamicInputModel({ + id: 'groupName', + label: this.translateService.instant(`${this.messagePrefix}.groupName`), + name: 'groupName', + validators: { + required: null, + }, + required: true, + }); + const groupCommunityModel = new DynamicInputModel({ + id: 'groupCommunity', + label: this.translateService.instant(`${this.messagePrefix}.groupCommunity`), + name: 'groupCommunity', + required: false, + readOnly: true, + }); + const groupDescriptionModel = new DynamicTextAreaModel({ + id: 'groupDescription', + label: this.translateService.instant(`${this.messagePrefix}.groupDescription`), + name: 'groupDescription', + required: false, + spellCheck: environment.form.spellCheck, + }); + this.formModel = [ + groupNameModel, + groupDescriptionModel, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.groupName = this.formGroup.get('groupName'); + this.groupDescription = this.formGroup.get('groupDescription'); + + if (hasValue(this.groupName)) { + this.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); + this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.changeDetectorRef.detectChanges(); + }); + } + + this.subs.push( + observableCombineLatest([ + this.activeGroup$, + this.canEdit$, + this.activeGroupLinkedDSO$, + ]).subscribe(([activeGroup, canEdit, linkedObject]) => { + + if (activeGroup != null) { + + // Disable group name exists validator + this.formGroup.controls.groupName.clearAsyncValidators(); + + if (isNotEmpty(linkedObject?.name)) { + if (!this.formGroup.controls.groupCommunity) { + this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, groupCommunityModel); + this.groupDescription = this.formGroup.get('groupCommunity'); + } + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupCommunity: linkedObject?.name ?? '', + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); + } else { + this.formModel = [ + groupNameModel, + groupDescriptionModel, + ]; + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); + } + if (!canEdit || activeGroup.permanent) { + this.formGroup.disable(); + } else { + this.formGroup.enable(); + } + } }), ); - observableCombineLatest([ - this.translateService.get(`${this.messagePrefix}.groupName`), - this.translateService.get(`${this.messagePrefix}.groupCommunity`), - this.translateService.get(`${this.messagePrefix}.groupDescription`), - ]).subscribe(([groupName, groupCommunity, groupDescription]) => { - this.groupName = new DynamicInputModel({ - id: 'groupName', - label: groupName, - name: 'groupName', - validators: { - required: null, - }, - required: true, - }); - this.groupCommunity = new DynamicInputModel({ - id: 'groupCommunity', - label: groupCommunity, - name: 'groupCommunity', - required: false, - readOnly: true, - }); - this.groupDescription = new DynamicTextAreaModel({ - id: 'groupDescription', - label: groupDescription, - name: 'groupDescription', - required: false, - spellCheck: environment.form.spellCheck, - }); - this.formModel = [ - this.groupName, - this.groupDescription, - ]; - this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - - if (this.formGroup.controls.groupName) { - this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService)); - this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => { - this.changeDetectorRef.detectChanges(); - }); - } - - this.subs.push( - observableCombineLatest([ - this.groupDataService.getActiveGroup(), - this.canEdit$, - this.groupDataService.getActiveGroup() - .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))), - ]).subscribe(([activeGroup, canEdit, linkedObject]) => { - - if (activeGroup != null) { - - // Disable group name exists validator - this.formGroup.controls.groupName.clearAsyncValidators(); - - this.groupBeingEdited = activeGroup; - - if (linkedObject?.name) { - if (!this.formGroup.controls.groupCommunity) { - this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); - this.formGroup.patchValue({ - groupName: activeGroup.name, - groupCommunity: linkedObject?.name ?? '', - groupDescription: activeGroup.firstMetadataValue('dc.description'), - }); - } - } else { - this.formModel = [ - this.groupName, - this.groupDescription, - ]; - this.formGroup.patchValue({ - groupName: activeGroup.name, - groupDescription: activeGroup.firstMetadataValue('dc.description'), - }); - } - setTimeout(() => { - if (!canEdit || activeGroup.permanent) { - this.formGroup.disable(); - } - }, 200); - } - }), - ); - }); } /** @@ -324,9 +327,9 @@ export class GroupFormComponent implements OnInit, OnDestroy { * Emit the updated/created eperson using the EventEmitter submitForm */ onSubmit() { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe( - (group: Group) => { - const values = { + this.activeGroup$.pipe(take(1)).subscribe((group: Group) => { + if (group === null) { + this.createNewGroup({ name: this.groupName.value, metadata: { 'dc.description': [ @@ -335,14 +338,11 @@ export class GroupFormComponent implements OnInit, OnDestroy { }, ], }, - }; - if (group === null) { - this.createNewGroup(values); - } else { - this.editGroup(group); - } - }, - ); + }); + } else { + this.editGroup(group); + } + }); } /** @@ -448,7 +448,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { * @param groupSelfLink SelfLink of group to set as active */ setActiveGroupWithLink(groupSelfLink: string) { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { + this.activeGroup$.pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup === null) { this.groupDataService.cancelEditGroup(); this.groupDataService.findByHref(groupSelfLink, false, false, followLink('subgroups'), followLink('epersons'), followLink('object')) @@ -467,7 +467,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { * It'll either show a success or error message depending on whether the delete was successful or not. */ delete() { - this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { + this.activeGroup$.pipe(take(1)).subscribe((group: Group) => { const modalRef = this.modalService.open(ConfirmationModalComponent); modalRef.componentInstance.name = this.dsoNameService.getName(group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; @@ -504,59 +504,45 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.groupDataService.cancelEditGroup(); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - if ( hasValue(this.groupNameValueChangeSubscribe) ) { + if (hasValue(this.groupNameValueChangeSubscribe)) { this.groupNameValueChangeSubscribe.unsubscribe(); } } /** - * Check if group has a linked object (community or collection linked to a workflow group) - * @param group + * Get the active {@link Group}'s linked object if it has one ({@link Community} or {@link Collection} linked to a + * workflow group) */ - hasLinkedDSO(group: Group): Observable { - if (hasValue(group) && hasValue(group._links.object.href)) { - return this.getLinkedDSO(group).pipe( - map((rd: RemoteData) => { - return hasValue(rd) && hasValue(rd.payload); - }), - catchError(() => observableOf(false)), - ); - } + getActiveGroupLinkedDSO(): Observable { + return this.activeGroup$.pipe( + hasValueOperator(), + switchMap((group: Group) => { + if (group.object === undefined) { + return this.dSpaceObjectDataService.findByHref(group._links.object.href); + } + return group.object; + }), + getAllCompletedRemoteData(), + getRemoteDataPayload(), + ); } /** - * Get group's linked object if it has one (community or collection linked to a workflow group) - * @param group + * Get the route to the edit roles tab of the active {@link Group}'s linked object (community or collection linked + * to a workflow group) if it has one */ - getLinkedDSO(group: Group): Observable> { - if (hasValue(group) && hasValue(group._links.object.href)) { - if (group.object === undefined) { - return this.dSpaceObjectDataService.findByHref(group._links.object.href); - } - return group.object; - } - } - - /** - * Get the route to the edit roles tab of the group's linked object (community or collection linked to a workflow group) if it has one - * @param group - */ - getLinkedEditRolesRoute(group: Group): Observable { - if (hasValue(group) && hasValue(group._links.object.href)) { - return this.getLinkedDSO(group).pipe( - map((rd: RemoteData) => { - if (hasValue(rd) && hasValue(rd.payload)) { - const dso = rd.payload; - switch ((dso as any).type) { - case Community.type.value: - return getCommunityEditRolesRoute(rd.payload.id); - case Collection.type.value: - return getCollectionEditRolesRoute(rd.payload.id); - } - } - }), - ); - } + getLinkedEditRolesRoute(): Observable { + return this.activeGroupLinkedDSO$.pipe( + hasValueOperator(), + map((dso: DSpaceObject) => { + switch ((dso as any).type) { + case Community.type.value: + return getCommunityEditRolesRoute(dso.id); + case Collection.type.value: + return getCollectionEditRolesRoute(dso.id); + } + }), + ); } } diff --git a/src/app/accessibility/accessibility-settings.service.spec.ts b/src/app/accessibility/accessibility-settings.service.spec.ts index 8a72f2d433..64548ca84d 100644 --- a/src/app/accessibility/accessibility-settings.service.spec.ts +++ b/src/app/accessibility/accessibility-settings.service.spec.ts @@ -18,7 +18,9 @@ import { ACCESSIBILITY_COOKIE, ACCESSIBILITY_SETTINGS_METADATA_KEY, AccessibilitySettings, - AccessibilitySettingsService, AccessibilitySettingsFormValues, FullAccessibilitySettings, + AccessibilitySettingsFormValues, + AccessibilitySettingsService, + FullAccessibilitySettings, } from './accessibility-settings.service'; diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 912e931f40..ccdfd3dfec 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -9,9 +9,9 @@
@@ -26,12 +26,12 @@ - +
-
diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 248e60ca4b..ec4ddce92e 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -8,10 +8,7 @@ import { By } from '@angular/platform-browser'; import { RouterModule } from '@angular/router'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { - cold, - hot, -} from 'jasmine-marbles'; +import { hot } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; @@ -191,17 +188,17 @@ describe('BitstreamFormatsComponent', () => { beforeEach(waitForAsync(initAsync)); beforeEach(initBeforeEach); it('should return an observable of true if the provided bistream is in the list returned by the service', () => { - const result = comp.isSelected(bitstreamFormat1); - - expect(result).toBeObservable(cold('b', { b: true })); + comp.selectedBitstreamFormatIDs().subscribe((selectedBitstreamFormatIDs: string[]) => { + expect(selectedBitstreamFormatIDs).toContain(bitstreamFormat1.id); + }); }); it('should return an observable of false if the provided bistream is not in the list returned by the service', () => { const format = new BitstreamFormat(); format.uuid = 'new'; - const result = comp.isSelected(format); - - expect(result).toBeObservable(cold('b', { b: false })); + comp.selectedBitstreamFormatIDs().subscribe((selectedBitstreamFormatIDs: string[]) => { + expect(selectedBitstreamFormatIDs).not.toContain(format.id); + }); }); }); diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index 72da2fadfd..6cc3433202 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -13,10 +13,7 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; -import { - combineLatest as observableCombineLatest, - Observable, -} from 'rxjs'; +import { Observable } from 'rxjs'; import { map, mergeMap, @@ -58,7 +55,12 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { /** * A paginated list of bitstream formats to be shown on the page */ - bitstreamFormats: Observable>>; + bitstreamFormats$: Observable>>; + + /** + * The currently selected {@link BitstreamFormat} IDs + */ + selectedBitstreamFormatIDs$: Observable; /** * The current pagination configuration for the page @@ -125,14 +127,11 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { } /** - * Checks whether a given bitstream format is selected in the list (checkbox) - * @param bitstreamFormat + * Returns the list of all the bitstream formats that are selected in the list (checkbox) */ - isSelected(bitstreamFormat: BitstreamFormat): Observable { + selectedBitstreamFormatIDs(): Observable { return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe( - map((bitstreamFormats: BitstreamFormat[]) => { - return bitstreamFormats.find((selectedFormat) => selectedFormat.id === bitstreamFormat.id) != null; - }), + map((bitstreamFormats: BitstreamFormat[]) => bitstreamFormats.map((selectedFormat) => selectedFormat.id)), ); } @@ -156,27 +155,23 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy { const prefix = 'admin.registries.bitstream-formats.delete'; const suffix = success ? 'success' : 'failure'; - const messages = observableCombineLatest( - this.translateService.get(`${prefix}.${suffix}.head`), - this.translateService.get(`${prefix}.${suffix}.amount`, { amount: amount }), - ); - messages.subscribe(([head, content]) => { + const head: string = this.translateService.instant(`${prefix}.${suffix}.head`); + const content: string = this.translateService.instant(`${prefix}.${suffix}.amount`, { amount: amount }); - if (success) { - this.notificationsService.success(head, content); - } else { - this.notificationsService.error(head, content); - } - }); + if (success) { + this.notificationsService.success(head, content); + } else { + this.notificationsService.error(head, content); + } } ngOnInit(): void { - - this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe( + this.bitstreamFormats$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe( switchMap((findListOptions: FindListOptions) => { return this.bitstreamFormatService.findAll(findListOptions); }), ); + this.selectedBitstreamFormatIDs$ = this.selectedBitstreamFormatIDs(); } diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html index 63242b4795..8b3c6fe972 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -27,14 +27,14 @@ + [ngClass]="{'table-primary' : (activeMetadataSchema$ | async)?.id === schema.id}"> {{schema.id}} diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index 8fe31a0fd4..4ee61c35d5 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -17,9 +17,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service'; -import { RestResponse } from '../../../core/cache/response.models'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; -import { buildPaginatedList } from '../../../core/data/paginated-list.model'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; @@ -36,7 +34,9 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.u import { HostWindowServiceStub } from '../../../shared/testing/host-window-service.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { RegistryServiceStub } from '../../../shared/testing/registry.service.stub'; import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service.stub'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { MetadataRegistryComponent } from './metadata-registry.component'; import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-schema-form.component'; @@ -44,9 +44,11 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch describe('MetadataRegistryComponent', () => { let comp: MetadataRegistryComponent; let fixture: ComponentFixture; - let registryService: RegistryService; - let paginationService; - const mockSchemasList = [ + + let paginationService: PaginationServiceStub; + let registryService: RegistryServiceStub; + + const mockSchemasList: MetadataSchema[] = [ { id: 1, _links: { @@ -67,25 +69,7 @@ describe('MetadataRegistryComponent', () => { prefix: 'mock', namespace: 'http://dspace.org/mockschema', }, - ]; - const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - const registryServiceStub = { - getMetadataSchemas: () => mockSchemas, - getActiveMetadataSchema: () => observableOf(undefined), - getSelectedMetadataSchemas: () => observableOf([]), - editMetadataSchema: (schema) => { - }, - cancelEditMetadataSchema: () => { - }, - deleteMetadataSchema: () => observableOf(new RestResponse(true, 200, 'OK')), - deselectAllMetadataSchema: () => { - }, - clearMetadataSchemaRequests: () => observableOf(undefined), - }; - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - - paginationService = new PaginationServiceStub(); + ] as MetadataSchema[]; const configurationDataService = jasmine.createSpyObj('configurationDataService', { findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { @@ -109,6 +93,10 @@ describe('MetadataRegistryComponent', () => { ); beforeEach(waitForAsync(() => { + paginationService = new PaginationServiceStub(); + registryService = new RegistryServiceStub(); + spyOn(registryService, 'getMetadataSchemas').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList))); + TestBed.configureTestingModule({ imports: [ CommonModule, @@ -120,7 +108,7 @@ describe('MetadataRegistryComponent', () => { EnumKeysPipe, ], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, + { provide: RegistryService, useValue: registryService }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: PaginationService, useValue: paginationService }, { @@ -190,7 +178,7 @@ describe('MetadataRegistryComponent', () => { })); it('should cancel editing the selected schema when clicked again', waitForAsync(() => { - spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(mockSchemasList[0] as MetadataSchema)); + comp.activeMetadataSchema$ = observableOf(mockSchemasList[0] as MetadataSchema); spyOn(registryService, 'cancelEditMetadataSchema'); row.click(); fixture.detectChanges(); @@ -205,7 +193,7 @@ describe('MetadataRegistryComponent', () => { beforeEach(() => { spyOn(registryService, 'deleteMetadataSchema').and.callThrough(); - spyOn(registryService, 'getSelectedMetadataSchemas').and.returnValue(observableOf(selectedSchemas as MetadataSchema[])); + comp.selectedMetadataSchemaIDs$ = observableOf(selectedSchemas.map((selectedSchema: MetadataSchema) => selectedSchema.id)); comp.deleteSchemas(); fixture.detectChanges(); }); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts index be989d49e2..be1239ab95 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -7,19 +7,17 @@ import { import { Component, OnDestroy, + OnInit, } from '@angular/core'; -import { - Router, - RouterLink, -} from '@angular/router'; +import { RouterLink } from '@angular/router'; import { TranslateModule, TranslateService, } from '@ngx-translate/core'; import { BehaviorSubject, - combineLatest as observableCombineLatest, Observable, + Subscription, zip, } from 'rxjs'; import { @@ -36,7 +34,6 @@ import { PaginationService } from '../../../core/pagination/pagination.service'; import { RegistryService } from '../../../core/registry/registry.service'; import { NoContent } from '../../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; -import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { toFindListOptions } from '../../../shared/pagination/pagination.utils'; @@ -63,13 +60,23 @@ import { MetadataSchemaFormComponent } from './metadata-schema-form/metadata-sch * A component used for managing all existing metadata schemas within the repository. * The admin can create, edit or delete metadata schemas here. */ -export class MetadataRegistryComponent implements OnDestroy { +export class MetadataRegistryComponent implements OnDestroy, OnInit { /** * A list of all the current metadata schemas within the repository */ metadataSchemas: Observable>>; + /** + * The {@link MetadataSchema}that is being edited + */ + activeMetadataSchema$: Observable; + + /** + * The selected {@link MetadataSchema} IDs + */ + selectedMetadataSchemaIDs$: Observable; + /** * 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 = new BehaviorSubject(true); - constructor(private registryService: RegistryService, - private notificationsService: NotificationsService, - private router: Router, - private paginationService: PaginationService, - private translateService: TranslateService) { + subscriptions: Subscription[] = []; + + constructor( + protected registryService: RegistryService, + protected notificationsService: NotificationsService, + protected paginationService: PaginationService, + protected translateService: TranslateService, + ) { + } + + ngOnInit(): void { + this.activeMetadataSchema$ = this.registryService.getActiveMetadataSchema(); + this.selectedMetadataSchemaIDs$ = this.registryService.getSelectedMetadataSchemas().pipe( + map((schemas: MetadataSchema[]) => schemas.map((schema: MetadataSchema) => schema.id)), + ); this.updateSchemas(); } @@ -116,30 +133,13 @@ export class MetadataRegistryComponent implements OnDestroy { * @param schema */ editSchema(schema: MetadataSchema) { - this.getActiveSchema().pipe(take(1)).subscribe((activeSchema) => { + this.subscriptions.push(this.activeMetadataSchema$.pipe(take(1)).subscribe((activeSchema: MetadataSchema) => { if (schema === activeSchema) { this.registryService.cancelEditMetadataSchema(); } else { this.registryService.editMetadataSchema(schema); } - }); - } - - /** - * Checks whether the given metadata schema is active (being edited) - * @param schema - */ - isActive(schema: MetadataSchema): Observable { - return this.getActiveSchema().pipe( - map((activeSchema) => schema === activeSchema), - ); - } - - /** - * Gets the active metadata schema (being edited) - */ - getActiveSchema(): Observable { - return this.registryService.getActiveMetadataSchema(); + })); } /** @@ -153,42 +153,25 @@ export class MetadataRegistryComponent implements OnDestroy { this.registryService.deselectMetadataSchema(schema); } - /** - * Checks whether a given metadata schema is selected in the list (checkbox) - * @param schema - */ - isSelected(schema: MetadataSchema): Observable { - return this.registryService.getSelectedMetadataSchemas().pipe( - map((schemas) => schemas.find((selectedSchema) => selectedSchema === schema) != null), - ); - } - /** * Delete all the selected metadata schemas */ deleteSchemas() { - this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( - (schemas) => { - const tasks$ = []; - for (const schema of schemas) { - if (hasValue(schema.id)) { - tasks$.push(this.registryService.deleteMetadataSchema(schema.id).pipe(getFirstCompletedRemoteData())); - } - } - zip(...tasks$).subscribe((responses: RemoteData[]) => { - const successResponses = responses.filter((response: RemoteData) => response.hasSucceeded); - const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); - if (successResponses.length > 0) { - this.showNotification(true, successResponses.length); - } - if (failedResponses.length > 0) { - this.showNotification(false, failedResponses.length); - } - this.registryService.deselectAllMetadataSchema(); - this.registryService.cancelEditMetadataSchema(); - }); - }, - ); + this.subscriptions.push(this.selectedMetadataSchemaIDs$.pipe( + take(1), + switchMap((schemaIDs: number[]) => zip(schemaIDs.map((schemaID: number) => this.registryService.deleteMetadataSchema(schemaID).pipe(getFirstCompletedRemoteData())))), + ).subscribe((responses: RemoteData[]) => { + const successResponses: RemoteData[] = responses.filter((response: RemoteData) => response.hasSucceeded); + const failedResponses: RemoteData[] = responses.filter((response: RemoteData) => response.hasFailed); + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); + } + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + this.registryService.deselectAllMetadataSchema(); + this.registryService.cancelEditMetadataSchema(); + })); } /** @@ -199,20 +182,20 @@ export class MetadataRegistryComponent implements OnDestroy { showNotification(success: boolean, amount: number) { const prefix = 'admin.registries.schema.notification'; const suffix = success ? 'success' : 'failure'; - const messages = observableCombineLatest( - this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), - this.translateService.get(`${prefix}.deleted.${suffix}`, { amount: amount }), - ); - messages.subscribe(([head, content]) => { - if (success) { - this.notificationsService.success(head, content); - } else { - this.notificationsService.error(head, content); - } - }); + + const head: string = this.translateService.instant(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`); + const content: string = this.translateService.instant(`${prefix}.deleted.${suffix}`, { amount: amount }); + + if (success) { + this.notificationsService.success(head, content); + } else { + this.notificationsService.error(head, content); + } } + ngOnDestroy(): void { this.paginationService.clearPagination(this.config.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html index 85d1e90692..15cc81d9ca 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html @@ -1,4 +1,4 @@ -
+

{{messagePrefix + '.create' | translate}}

diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 8a08b59800..f98e274324 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, inject, @@ -16,42 +16,26 @@ import { RegistryService } from '../../../../core/registry/registry.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../../shared/form/form.component'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RegistryServiceStub } from '../../../../shared/testing/registry.service.stub'; import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe'; import { MetadataSchemaFormComponent } from './metadata-schema-form.component'; describe('MetadataSchemaFormComponent', () => { let component: MetadataSchemaFormComponent; let fixture: ComponentFixture; - let registryService: RegistryService; - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - const registryServiceStub = { - getActiveMetadataSchema: () => observableOf(undefined), - createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), - cancelEditMetadataSchema: () => { - }, - clearMetadataSchemaRequests: () => observableOf(undefined), - }; - const formBuilderServiceStub = { - createFormGroup: () => { - return { - patchValue: () => { - }, - reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void { - }, - }; - }, - }; - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ + let registryService: RegistryServiceStub; beforeEach(waitForAsync(() => { + registryService = new RegistryServiceStub(); + return TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataSchemaFormComponent, EnumKeysPipe], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, + { provide: RegistryService, useValue: registryService }, { provide: FormBuilderService, useValue: getMockFormBuilderService() }, ], - schemas: [NO_ERRORS_SCHEMA], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .overrideComponent(MetadataSchemaFormComponent, { remove: { @@ -88,7 +72,7 @@ describe('MetadataSchemaFormComponent', () => { describe('without an active schema', () => { beforeEach(() => { - spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(undefined)); + component.activeMetadataSchema$ = observableOf(undefined); component.onSubmit(); fixture.detectChanges(); }); @@ -107,7 +91,7 @@ describe('MetadataSchemaFormComponent', () => { } as MetadataSchema); beforeEach(() => { - spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId)); + component.activeMetadataSchema$ = observableOf(expectedWithId); component.onSubmit(); fixture.detectChanges(); }); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts index 068c7f1bab..c58c4bef10 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts @@ -21,13 +21,13 @@ import { TranslateService, } from '@ngx-translate/core'; import { - combineLatest, Observable, + Subscription, } from 'rxjs'; import { + map, switchMap, take, - tap, } from 'rxjs/operators'; import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; @@ -102,64 +102,71 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { */ @Output() submitForm: EventEmitter = new EventEmitter(); - constructor(public registryService: RegistryService, private formBuilderService: FormBuilderService, private translateService: TranslateService) { + /** + * The {@link MetadataSchema} that is currently being edited + */ + activeMetadataSchema$: Observable; + + subscriptions: Subscription[] = []; + + constructor( + protected registryService: RegistryService, + protected formBuilderService: FormBuilderService, + protected translateService: TranslateService, + ) { } ngOnInit() { - combineLatest([ - this.translateService.get(`${this.messagePrefix}.name`), - this.translateService.get(`${this.messagePrefix}.namespace`), - ]).subscribe(([name, namespace]) => { - this.name = new DynamicInputModel({ - id: 'name', - label: name, - name: 'name', - validators: { - required: null, - pattern: '^[^. ,]*$', - maxLength: 32, - }, - required: true, - errorMessages: { - pattern: 'error.validation.metadata.name.invalid-pattern', - maxLength: 'error.validation.metadata.name.max-length', - }, - }); - this.namespace = new DynamicInputModel({ - id: 'namespace', - label: namespace, - name: 'namespace', - validators: { - required: null, - maxLength: 256, - }, - required: true, - errorMessages: { - maxLength: 'error.validation.metadata.namespace.max-length', - }, - }); - this.formModel = [ - new DynamicFormGroupModel( - { - id: 'metadatadataschemagroup', - group:[this.namespace, this.name], - }), - ]; - this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - this.registryService.getActiveMetadataSchema().subscribe((schema: MetadataSchema) => { - if (schema == null) { - this.clearFields(); - } else { - this.formGroup.patchValue({ - metadatadataschemagroup: { - name: schema.prefix, - namespace: schema.namespace, - }, - }); - this.name.disabled = true; - } - }); + this.name = new DynamicInputModel({ + id: 'name', + label: this.translateService.instant(`${this.messagePrefix}.name`), + name: 'name', + validators: { + required: null, + pattern: '^[^. ,]*$', + maxLength: 32, + }, + required: true, + errorMessages: { + pattern: 'error.validation.metadata.name.invalid-pattern', + maxLength: 'error.validation.metadata.name.max-length', + }, }); + this.namespace = new DynamicInputModel({ + id: 'namespace', + label: this.translateService.instant(`${this.messagePrefix}.namespace`), + name: 'namespace', + validators: { + required: null, + maxLength: 256, + }, + required: true, + errorMessages: { + maxLength: 'error.validation.metadata.namespace.max-length', + }, + }); + this.formModel = [ + new DynamicFormGroupModel( + { + id: 'metadatadataschemagroup', + group:[this.namespace, this.name], + }), + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.activeMetadataSchema$ = this.registryService.getActiveMetadataSchema(); + this.subscriptions.push(this.activeMetadataSchema$.subscribe((schema: MetadataSchema) => { + if (schema == null) { + this.clearFields(); + } else { + this.formGroup.patchValue({ + metadatadataschemagroup: { + name: schema.prefix, + namespace: schema.namespace, + }, + }); + this.name.disabled = true; + } + })); } /** @@ -176,48 +183,29 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { * Emit the updated/created schema using the EventEmitter submitForm */ onSubmit(): void { - this.registryService - .getActiveMetadataSchema() - .pipe( - take(1), - switchMap((schema: MetadataSchema) => { - const metadataValues = { - prefix: this.name.value, - namespace: this.namespace.value, - }; - - let createOrUpdate$: Observable; - - if (schema == null) { - createOrUpdate$ = - this.registryService.createOrUpdateMetadataSchema( - Object.assign(new MetadataSchema(), metadataValues), - ); - } else { - const updatedSchema = Object.assign( - new MetadataSchema(), - schema, - { - namespace: metadataValues.namespace, - }, - ); - createOrUpdate$ = - this.registryService.createOrUpdateMetadataSchema( - updatedSchema, - ); - } - - return createOrUpdate$; - }), - tap(() => { - this.registryService.clearMetadataSchemaRequests().subscribe(); - }), - ) - .subscribe((updatedOrCreatedSchema: MetadataSchema) => { - this.submitForm.emit(updatedOrCreatedSchema); - this.clearFields(); - this.registryService.cancelEditMetadataSchema(); - }); + this.activeMetadataSchema$.pipe( + take(1), + switchMap((schema: MetadataSchema) => { + const metadataValues = { + prefix: this.name.value, + namespace: this.namespace.value, + }; + if (schema == null) { + return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), metadataValues)); + } else { + return this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, { + namespace: metadataValues.namespace, + })); + } + }), + switchMap((updatedOrCreatedSchema: MetadataSchema) => this.registryService.clearMetadataSchemaRequests().pipe( + map(() => updatedOrCreatedSchema), + )), + ).subscribe((updatedOrCreatedSchema: MetadataSchema) => { + this.submitForm.emit(updatedOrCreatedSchema); + this.clearFields(); + this.registryService.cancelEditMetadataSchema(); + }); } /** @@ -233,5 +221,6 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { */ ngOnDestroy(): void { this.onCancel(); + this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 8044811ece..440f52a24d 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, inject, @@ -17,13 +17,15 @@ import { RegistryService } from '../../../../core/registry/registry.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../../shared/form/form.component'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; +import { RegistryServiceStub } from '../../../../shared/testing/registry.service.stub'; import { EnumKeysPipe } from '../../../../shared/utils/enum-keys-pipe'; import { MetadataFieldFormComponent } from './metadata-field-form.component'; describe('MetadataFieldFormComponent', () => { let component: MetadataFieldFormComponent; let fixture: ComponentFixture; - let registryService: RegistryService; + + let registryService: RegistryServiceStub; const metadataSchema = Object.assign(new MetadataSchema(), { id: 1, @@ -31,37 +33,16 @@ describe('MetadataFieldFormComponent', () => { prefix: 'fake', }); - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - const registryServiceStub = { - getActiveMetadataField: () => observableOf(undefined), - createMetadataField: (field: MetadataField) => observableOf(field), - updateMetadataField: (field: MetadataField) => observableOf(field), - cancelEditMetadataField: () => { - }, - cancelEditMetadataSchema: () => { - }, - clearMetadataFieldRequests: () => observableOf(undefined), - }; - const formBuilderServiceStub = { - createFormGroup: () => { - return { - patchValue: () => { - }, - reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void { - }, - }; - }, - }; - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - beforeEach(waitForAsync(() => { + registryService = new RegistryServiceStub(); + return TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, MetadataFieldFormComponent, EnumKeysPipe], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, + { provide: RegistryService, useValue: registryService }, { provide: FormBuilderService, useValue: getMockFormBuilderService() }, ], - schemas: [NO_ERRORS_SCHEMA], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .overrideComponent(MetadataFieldFormComponent, { remove: { imports: [FormComponent] }, diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html index f748279a1d..288266fb75 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -31,8 +31,8 @@ - + [ngClass]="{'table-primary' : (activeField$ | async)?.id === field.id}"> + { let comp: MetadataSchemaComponent; let fixture: ComponentFixture; - let registryService: RegistryService; - const mockSchemasList = [ + + let registryService: RegistryServiceStub; + let activatedRoute: ActivatedRouteStub; + let paginationService: PaginationServiceStub; + + const mockSchemasList: MetadataSchema[] = [ { id: 1, _links: { @@ -67,8 +67,8 @@ describe('MetadataSchemaComponent', () => { prefix: 'mock', namespace: 'http://dspace.org/mockschema', }, - ]; - const mockFieldsList = [ + ] as MetadataSchema[]; + const mockFieldsList: MetadataField[] = [ { id: 1, _links: { @@ -117,33 +117,8 @@ describe('MetadataSchemaComponent', () => { scopeNote: null, schema: createSuccessfulRemoteDataObject$(mockSchemasList[1]), }, - ]; - const mockSchemas = createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockSchemasList)); - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - const registryServiceStub = { - getMetadataSchemas: () => mockSchemas, - getMetadataFieldsBySchema: (schema: MetadataSchema) => createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4))), - getMetadataSchemaByPrefix: (schemaName: string) => createSuccessfulRemoteDataObject$(mockSchemasList.filter((value) => value.prefix === schemaName)[0]), - getActiveMetadataField: () => observableOf(undefined), - getSelectedMetadataFields: () => observableOf([]), - editMetadataField: (schema) => { - }, - cancelEditMetadataField: () => { - }, - deleteMetadataField: () => observableOf(new RestResponse(true, 200, 'OK')), - deselectAllMetadataField: () => { - }, - clearMetadataFieldRequests: () => observableOf(undefined), - }; - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ + ] as MetadataField[]; const schemaNameParam = 'mock'; - const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: observableOf({ - schemaName: schemaNameParam, - }), - }); - - const paginationService = new PaginationServiceStub(); const configurationDataService = jasmine.createSpyObj('configurationDataService', { findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { @@ -162,6 +137,14 @@ describe('MetadataSchemaComponent', () => { beforeEach(waitForAsync(() => { + activatedRoute = new ActivatedRouteStub({ + schemaName: schemaNameParam, + }); + paginationService = new PaginationServiceStub(); + registryService = new RegistryServiceStub(); + spyOn(registryService, 'getMetadataFieldsBySchema').and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(null, mockFieldsList.filter((value) => value.id === 3 || value.id === 4)))); + spyOn(registryService, 'getMetadataSchemaByPrefix').and.callFake((schemaName) => createSuccessfulRemoteDataObject$(mockSchemasList.filter((value) => value.prefix === schemaName)[0])); + TestBed.configureTestingModule({ imports: [ CommonModule, @@ -174,10 +157,9 @@ describe('MetadataSchemaComponent', () => { VarDirective, ], providers: [ - { provide: RegistryService, useValue: registryServiceStub }, - { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: RegistryService, useValue: registryService }, + { provide: ActivatedRoute, useValue: activatedRoute }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, - { provide: Router, useValue: new RouterStub() }, { provide: PaginationService, useValue: paginationService }, { provide: NotificationsService, @@ -187,7 +169,7 @@ describe('MetadataSchemaComponent', () => { { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], - schemas: [NO_ERRORS_SCHEMA], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) .overrideComponent(MetadataSchemaComponent, { remove: { @@ -242,7 +224,7 @@ describe('MetadataSchemaComponent', () => { })); it('should cancel editing the selected field when clicked again', waitForAsync(() => { - spyOn(registryService, 'getActiveMetadataField').and.returnValue(observableOf(mockFieldsList[2] as MetadataField)); + comp.activeField$ = observableOf(mockFieldsList[2] as MetadataField); spyOn(registryService, 'cancelEditMetadataField'); row.click(); fixture.detectChanges(); @@ -257,7 +239,7 @@ describe('MetadataSchemaComponent', () => { beforeEach(() => { spyOn(registryService, 'deleteMetadataField').and.callThrough(); - spyOn(registryService, 'getSelectedMetadataFields').and.returnValue(observableOf(selectedFields as MetadataField[])); + comp.selectedMetadataFieldIDs$ = observableOf(selectedFields.map((metadataField: MetadataField) => metadataField.id)); comp.deleteFields(); fixture.detectChanges(); }); diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts index c5c1d650b1..ec5d6b4cb0 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -20,9 +20,9 @@ import { import { BehaviorSubject, combineLatest, - combineLatest as observableCombineLatest, Observable, of as observableOf, + Subscription, zip, } from 'rxjs'; import { @@ -42,7 +42,6 @@ import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, } from '../../../core/shared/operators'; -import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { toFindListOptions } from '../../../shared/pagination/pagination.utils'; @@ -71,7 +70,7 @@ import { MetadataFieldFormComponent } from './metadata-field-form/metadata-field * A component used for managing all existing metadata fields within the current metadata schema. * The admin can create, edit or delete metadata fields here. */ -export class MetadataSchemaComponent implements OnInit, OnDestroy { +export class MetadataSchemaComponent implements OnDestroy, OnInit { /** * The metadata schema */ @@ -96,26 +95,33 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy { */ needsUpdate$: BehaviorSubject = new BehaviorSubject(true); - constructor(private registryService: RegistryService, - private route: ActivatedRoute, - private notificationsService: NotificationsService, - private paginationService: PaginationService, - private translateService: TranslateService) { + /** + * The current {@link MetadataField} that is being edited + */ + activeField$: Observable; + /** + * The selected {@link MetadataField} IDs + */ + selectedMetadataFieldIDs$: Observable; + + subscriptions: Subscription[] = []; + + constructor( + protected registryService: RegistryService, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected paginationService: PaginationService, + protected translateService: TranslateService, + ) { } ngOnInit(): void { - this.route.params.subscribe((params) => { - this.initialize(params); - }); - } - - /** - * Initialize the component using the params within the url (schemaName) - * @param params - */ - initialize(params) { - this.metadataSchema$ = this.registryService.getMetadataSchemaByPrefix(params.schemaName).pipe(getFirstSucceededRemoteDataPayload()); + this.metadataSchema$ = this.registryService.getMetadataSchemaByPrefix(this.route.snapshot.params.schemaName).pipe(getFirstSucceededRemoteDataPayload()); + this.activeField$ = this.registryService.getActiveMetadataField(); + this.selectedMetadataFieldIDs$ = this.registryService.getSelectedMetadataFields().pipe( + map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => metadataField.id)), + ); this.updateFields(); } @@ -148,30 +154,13 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy { * @param field */ editField(field: MetadataField) { - this.getActiveField().pipe(take(1)).subscribe((activeField) => { + this.subscriptions.push(this.activeField$.pipe(take(1)).subscribe((activeField) => { if (field === activeField) { this.registryService.cancelEditMetadataField(); } else { this.registryService.editMetadataField(field); } - }); - } - - /** - * Checks whether the given metadata field is active (being edited) - * @param field - */ - isActive(field: MetadataField): Observable { - return this.getActiveField().pipe( - map((activeField) => field === activeField), - ); - } - - /** - * Gets the active metadata field (being edited) - */ - getActiveField(): Observable { - return this.registryService.getActiveMetadataField(); + })); } /** @@ -185,42 +174,25 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy { this.registryService.deselectMetadataField(field); } - /** - * Checks whether a given metadata field is selected in the list (checkbox) - * @param field - */ - isSelected(field: MetadataField): Observable { - return this.registryService.getSelectedMetadataFields().pipe( - map((fields) => fields.find((selectedField) => selectedField === field) != null), - ); - } - /** * Delete all the selected metadata fields */ deleteFields() { - this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe( - (fields) => { - const tasks$ = []; - for (const field of fields) { - if (hasValue(field.id)) { - tasks$.push(this.registryService.deleteMetadataField(field.id).pipe(getFirstCompletedRemoteData())); - } - } - zip(...tasks$).subscribe((responses: RemoteData[]) => { - const successResponses = responses.filter((response: RemoteData) => response.hasSucceeded); - const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); - if (successResponses.length > 0) { - this.showNotification(true, successResponses.length); - } - if (failedResponses.length > 0) { - this.showNotification(false, failedResponses.length); - } - this.registryService.deselectAllMetadataField(); - this.registryService.cancelEditMetadataField(); - }); - }, - ); + this.subscriptions.push(this.selectedMetadataFieldIDs$.pipe( + take(1), + switchMap((fieldIDs) => zip(fieldIDs.map((fieldID) => this.registryService.deleteMetadataField(fieldID).pipe(getFirstCompletedRemoteData())))), + ).subscribe((responses: RemoteData[]) => { + const successResponses = responses.filter((response: RemoteData) => response.hasSucceeded); + const failedResponses = responses.filter((response: RemoteData) => response.hasFailed); + if (successResponses.length > 0) { + this.showNotification(true, successResponses.length); + } + if (failedResponses.length > 0) { + this.showNotification(false, failedResponses.length); + } + this.registryService.deselectAllMetadataField(); + this.registryService.cancelEditMetadataField(); + })); } /** @@ -231,21 +203,19 @@ export class MetadataSchemaComponent implements OnInit, OnDestroy { showNotification(success: boolean, amount: number) { const prefix = 'admin.registries.schema.notification'; const suffix = success ? 'success' : 'failure'; - const messages = observableCombineLatest([ - this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), - this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount }), - ]); - messages.subscribe(([head, content]) => { - if (success) { - this.notificationsService.success(head, content); - } else { - this.notificationsService.error(head, content); - } - }); + const head = this.translateService.instant(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`); + const content = this.translateService.instant(`${prefix}.field.deleted.${suffix}`, { amount: amount }); + if (success) { + this.notificationsService.success(head, content); + } else { + this.notificationsService.error(head, content); + } } + ngOnDestroy(): void { this.paginationService.clearPagination(this.config.id); this.registryService.deselectAllMetadataField(); + this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts b/src/app/bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts index 4bb89a85f4..d6133f2a97 100644 --- a/src/app/bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts +++ b/src/app/bitstream-page/bitstream-authorizations/bitstream-authorizations.component.ts @@ -19,7 +19,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { ResourcePoliciesComponent } from '../../shared/resource-policies/resource-policies.component'; @Component({ - selector: 'ds-collection-authorizations', + selector: 'ds-bitstream-authorizations', templateUrl: './bitstream-authorizations.component.html', imports: [ ResourcePoliciesComponent, @@ -30,7 +30,7 @@ import { ResourcePoliciesComponent } from '../../shared/resource-policies/resour standalone: true, }) /** - * Component that handles the Collection Authorizations + * Component that handles the Bitstream Authorizations */ export class BitstreamAuthorizationsComponent implements OnInit { diff --git a/src/app/browse-by/browse-by-date/browse-by-date.component.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.ts index ee33ee533c..11818ff5f1 100644 --- a/src/app/browse-by/browse-by-date/browse-by-date.component.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -18,7 +18,10 @@ import { combineLatest as observableCombineLatest, Observable, } 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 { @@ -53,7 +56,6 @@ import { VarDirective } from '../../shared/utils/var.directive'; import { BrowseByMetadataComponent, browseParamsToOptions, - getBrowseSearchOptions, } from '../browse-by-metadata/browse-by-metadata.component'; @Component({ @@ -104,15 +106,18 @@ export class BrowseByDateComponent extends BrowseByMetadataComponent implements ngOnInit(): void { const sortConfig = new SortOptions('default', SortDirection.ASC); 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.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.route.data, - this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, scope, data, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams, data), scope, currentPage, currentSort]; + observableCombineLatest( + [ this.route.params.pipe(take(1)), + this.route.queryParams, + 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]) => { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts index 29bc48b1bd..d17c2a1a7b 100644 --- a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts @@ -23,7 +23,10 @@ import { of as observableOf, Subscription, } 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 { @@ -216,7 +219,13 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy { this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); 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]) => { return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; }), diff --git a/src/app/browse-by/browse-by-page-routes.ts b/src/app/browse-by/browse-by-page-routes.ts index 9c7e16ab39..3843a50f6e 100644 --- a/src/app/browse-by/browse-by-page-routes.ts +++ b/src/app/browse-by/browse-by-page-routes.ts @@ -1,6 +1,5 @@ import { Route } from '@angular/router'; -import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver'; import { browseByGuard } from './browse-by-guard'; import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; @@ -11,7 +10,6 @@ export const ROUTES: Route[] = [ path: '', resolve: { breadcrumb: browseByDSOBreadcrumbResolver, - menu: dsoEditMenuResolver, }, children: [ { diff --git a/src/app/browse-by/browse-by-page/browse-by-page.component.ts b/src/app/browse-by/browse-by-page/browse-by-page.component.ts index 1a204264d1..670b5a7711 100644 --- a/src/app/browse-by/browse-by-page/browse-by-page.component.ts +++ b/src/app/browse-by/browse-by-page/browse-by-page.component.ts @@ -23,7 +23,7 @@ import { BrowseBySwitcherComponent } from '../browse-by-switcher/browse-by-switc }) export class BrowseByPageComponent implements OnInit { - browseByType$: Observable; + browseByType$: Observable<{type: BrowseByDataType }>; constructor( protected route: ActivatedRoute, @@ -35,7 +35,7 @@ export class BrowseByPageComponent implements OnInit { */ ngOnInit(): void { this.browseByType$ = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => data.browseDefinition.getRenderType()), + map((data: { browseDefinition: BrowseDefinition }) => ({ type: data.browseDefinition.getRenderType() })), ); } diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index d56671577e..8539365f66 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -85,7 +85,7 @@ describe('BrowseBySwitcherComponent', () => { types.forEach((type: NonHierarchicalBrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { beforeEach(async () => { - comp.browseByType = type.dataType; + comp.browseByType = type as any; comp.ngOnChanges({ browseByType: new SimpleChange(undefined, type.dataType, true), }); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index c1e3dae79f..53be5fa786 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -24,7 +24,7 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent< @Input() context: Context; - @Input() browseByType: BrowseByDataType; + @Input() browseByType: { type: BrowseByDataType }; @Input() displayTitle: boolean; @@ -43,7 +43,7 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent< ]; public getComponent(): GenericConstructor { - return getComponentByBrowseByType(this.browseByType, this.context, this.themeService.getThemeName()); + return getComponentByBrowseByType(this.browseByType.type, this.context, this.themeService.getThemeName()); } } diff --git a/src/app/browse-by/browse-by-title/browse-by-title.component.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.ts index aa5daa3166..296386d9d9 100644 --- a/src/app/browse-by/browse-by-title/browse-by-title.component.ts +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.ts @@ -9,7 +9,10 @@ import { import { Params } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { combineLatest as observableCombineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + map, + take, +} from 'rxjs/operators'; import { SortDirection, @@ -28,7 +31,6 @@ import { VarDirective } from '../../shared/utils/var.directive'; import { BrowseByMetadataComponent, browseParamsToOptions, - getBrowseSearchOptions, } from '../browse-by-metadata/browse-by-metadata.component'; @Component({ @@ -58,12 +60,16 @@ export class BrowseByTitleComponent extends BrowseByMetadataComponent implements ngOnInit(): void { 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.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); 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]) => { return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; }), diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index f2dadc3fbe..e20e3ba8af 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -54,7 +54,6 @@ export const ROUTES: Route[] = [ resolve: { dso: collectionPageResolver, breadcrumb: collectionBreadcrumbResolver, - menu: dsoEditMenuResolver, }, runGuardsAndResolvers: 'always', children: [ @@ -83,6 +82,9 @@ export const ROUTES: Route[] = [ { path: '', component: ThemedCollectionPageComponent, + resolve: { + menu: dsoEditMenuResolver, + }, children: [ { path: '', diff --git a/src/app/collection-page/create-collection-page/create-collection-page.component.html b/src/app/collection-page/create-collection-page/create-collection-page.component.html index 6ca5533924..5c1b7b32a5 100644 --- a/src/app/collection-page/create-collection-page/create-collection-page.component.html +++ b/src/app/collection-page/create-collection-page/create-collection-page.component.html @@ -1,8 +1,8 @@
-

{{ 'collection.create.sub-head' | translate:{ parent: dsoNameService.getName((parentRD$| async)?.payload) } }}

+

{{ 'collection.create.sub-head' | translate:{ parent: dsoNameService.getName((parentRD$| async)?.payload) } }}

- -

{{ 'community.create.sub-head' | translate:{ parent: dsoNameService.getName(parent) } }}

+

{{ 'community.create.head' | translate }}

+

{{ 'community.create.sub-head' | translate:{ parent: dsoNameService.getName(parent) } }}

diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index cb16cedb42..ef021123d4 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -7,7 +7,7 @@ import { import { Observable } from 'rxjs'; import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item-page/item.resolver'; +import { getItemPageLinksToFollow } from '../../item-page/item.resolver'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { ItemDataService } from '../data/item-data.service'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -24,7 +24,7 @@ export const itemBreadcrumbResolver: ResolveFn> = ( breadcrumbService: DSOBreadcrumbsService = inject(DSOBreadcrumbsService), dataService: ItemDataService = inject(ItemDataService), ): Observable> => { - const linksToFollow: FollowLinkConfig[] = ITEM_PAGE_LINKS_TO_FOLLOW as FollowLinkConfig[]; + const linksToFollow: FollowLinkConfig[] = getItemPageLinksToFollow() as FollowLinkConfig[]; return DSOBreadcrumbResolver( route, state, diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index f9999439e1..f3328c2bed 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -7,7 +7,6 @@ import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { createSuccessfulRemoteDataObject, @@ -18,7 +17,6 @@ import { createPaginatedList, getFirstUsedArgumentOfSpyMethod, } from '../../shared/testing/utils.test'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; import { RequestEntry } from '../data/request-entry.model'; import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; @@ -31,7 +29,6 @@ describe('BrowseService', () => { let scheduler: TestScheduler; let service: BrowseService; let requestService: RequestService; - let rdbService: RemoteDataBuildService; const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); @@ -129,7 +126,6 @@ describe('BrowseService', () => { halService, browseDefinitionDataService, hrefOnlyDataService, - rdbService, ); } @@ -141,11 +137,9 @@ describe('BrowseService', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(halService, 'getEndpoint').and .returnValue(hot('--a-', { a: browsesEndpointURL })); - spyOn(rdbService, 'buildList').and.callThrough(); }); it('should call BrowseDefinitionDataService to create the RemoteData Observable', () => { @@ -162,9 +156,7 @@ describe('BrowseService', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(rdbService, 'buildList').and.callThrough(); }); describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { @@ -215,7 +207,6 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions fires', () => { beforeEach(() => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('--a-', { @@ -270,7 +261,6 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions doesn\'t fire', () => { it('should return undefined', () => { requestService = getMockRequestService(getRequestEntry$(true)); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('----')); @@ -288,9 +278,7 @@ describe('BrowseService', () => { describe('getFirstItemFor', () => { beforeEach(() => { requestService = getMockRequestService(); - rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(rdbService, 'buildList').and.callThrough(); }); describe('when getFirstItemFor is called with a valid browse definition id', () => { diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 43f33f26e4..5fe06a700e 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -6,6 +6,7 @@ import { startWith, } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; import { hasValue, hasValueOperator, @@ -16,7 +17,6 @@ import { followLink, FollowLinkConfig, } from '../../shared/utils/follow-link-config.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SortDirection } from '../cache/models/sort-options.model'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { PaginatedList } from '../data/paginated-list.model'; @@ -38,9 +38,15 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('thumbnail'), -]; +export function getBrowseLinksToFollow(): FollowLinkConfig[] { + const followLinks = [ + followLink('thumbnail'), + ]; + if (environment.item.showAccessStatuses) { + followLinks.push(followLink('accessStatus')); + } + return followLinks; +} /** * The service handling all browse requests @@ -67,7 +73,6 @@ export class BrowseService { protected halService: HALEndpointService, private browseDefinitionDataService: BrowseDefinitionDataService, private hrefOnlyDataService: HrefOnlyDataService, - private rdb: RemoteDataBuildService, ) { } @@ -117,7 +122,7 @@ export class BrowseService { }), ); if (options.fetchThumbnail ) { - return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...getBrowseLinksToFollow()); } return this.hrefOnlyDataService.findListByHref(href$); } @@ -165,7 +170,7 @@ export class BrowseService { }), ); if (options.fetchThumbnail) { - return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...BROWSE_LINKS_TO_FOLLOW); + return this.hrefOnlyDataService.findListByHref(href$, {}, undefined, undefined, ...getBrowseLinksToFollow()); } return this.hrefOnlyDataService.findListByHref(href$); } diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 631ecd2209..553fd9b10a 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -174,20 +174,25 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi * the new state, with the object added, or overwritten. */ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { - const existing = state[action.payload.objectToCache._links.self.href] || {} as any; + const cacheLink = hasValue(action.payload.objectToCache?._links?.self) ? action.payload.objectToCache._links.self.href : action.payload.alternativeLink; + const existing = state[cacheLink] || {} as any; const newAltLinks = hasValue(action.payload.alternativeLink) ? [action.payload.alternativeLink] : []; - return Object.assign({}, state, { - [action.payload.objectToCache._links.self.href]: { - data: action.payload.objectToCache, - timeCompleted: action.payload.timeCompleted, - msToLive: action.payload.msToLive, - requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], - dependentRequestUUIDs: existing.dependentRequestUUIDs || [], - isDirty: isNotEmpty(existing.patches), - patches: existing.patches || [], - alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks], - } as ObjectCacheEntry, - }); + if (hasValue(cacheLink)) { + return Object.assign({}, state, { + [cacheLink]: { + data: action.payload.objectToCache, + timeCompleted: action.payload.timeCompleted, + msToLive: action.payload.msToLive, + requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], + dependentRequestUUIDs: existing.dependentRequestUUIDs || [], + isDirty: isNotEmpty(existing.patches), + patches: existing.patches || [], + alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks], + } as ObjectCacheEntry, + }); + } else { + return state; + } } /** diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 69242732a5..f645b5a878 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -99,7 +99,9 @@ export class ObjectCacheService { * An optional alternative link to this object */ add(object: CacheableObject, msToLive: number, requestUUID: string, alternativeLink?: string): void { - object = this.linkService.removeResolvedLinks(object); // Ensure the object we're storing has no resolved links + if (hasValue(object)) { + object = this.linkService.removeResolvedLinks(object); // Ensure the object we're storing has no resolved links + } this.store.dispatch(new AddToObjectCacheAction(object, new Date().getTime(), msToLive, requestUUID, alternativeLink)); } @@ -175,11 +177,15 @@ export class ObjectCacheService { }, ), map((entry: ObjectCacheEntry) => { - const type: GenericConstructor = getClassForType((entry.data as any).type); - if (typeof type !== 'function') { - throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); + if (hasValue(entry.data)) { + const type: GenericConstructor = getClassForType((entry.data as any).type); + if (typeof type !== 'function') { + throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); + } + return Object.assign(new type(), entry.data) as T; + } else { + return null; } - return Object.assign(new type(), entry.data) as T; }), ); } diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index feb4fc9cb0..13e19544dc 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -4,20 +4,16 @@ import { inheritSerialization, } from 'cerialize'; +import { + SectionScope, + SectionVisibility, +} from '../../../submission/objects/section-visibility.model'; import { SectionsType } from '../../../submission/sections/sections-type'; import { typedObject } from '../../cache/builders/build-decorators'; import { HALLink } from '../../shared/hal-link.model'; import { ConfigObject } from './config.model'; import { SUBMISSION_SECTION_TYPE } from './config-type'; -/** - * An interface that define section visibility and its properties. - */ -export interface SubmissionSectionVisibility { - main: any; - other: any; -} - @typedObject @inheritSerialization(ConfigObject) export class SubmissionSectionModel extends ConfigObject { @@ -35,6 +31,12 @@ export class SubmissionSectionModel extends ConfigObject { @autoserialize mandatory: boolean; + /** + * The submission scope for this section + */ + @autoserialize + scope: SectionScope; + /** * A string representing the kind of section object */ @@ -42,10 +44,10 @@ export class SubmissionSectionModel extends ConfigObject { sectionType: SectionsType; /** - * The [SubmissionSectionVisibility] object for this section + * The [SectionVisibility] object for this section */ @autoserialize - visibility: SubmissionSectionVisibility; + visibility: SectionVisibility; /** * The {@link HALLink}s for this SubmissionSectionModel diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 3f44ad5e5a..fc3704bce5 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -65,6 +65,7 @@ describe('BaseDataService', () => { let selfLink; let linksToFollow; let testScheduler; + let remoteDataTimestamp: number; let remoteDataMocks: { [responseType: string]: RemoteData }; let remoteDataPageMocks: { [responseType: string]: RemoteData }; @@ -85,7 +86,9 @@ describe('BaseDataService', () => { 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 payload = { foo: 'bar', @@ -112,22 +115,22 @@ describe('BaseDataService', () => { const statusCodeError = 404; const errorMessage = 'not found'; remoteDataMocks = { - RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), - ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), - ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), - Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), - SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), - Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), - ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; remoteDataPageMocks = { - RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), - ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), - ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), - Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), - SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), - Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), - ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + RequestPending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, remoteDataTimestamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(remoteDataTimestamp, msToLive, remoteDataTimestamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; return new TestService( @@ -361,11 +364,15 @@ describe('BaseDataService', () => { spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); }); - - it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + it('should not emit a cached completed RemoteData', () => { + // Old cached value from 1 minute before the test started + const oldCachedSucceededData: RemoteData = Object.assign({}, remoteDataPageMocks.Success, { + timeCompleted: remoteDataTimestamp - 2 * 60 * 1000, + lastUpdated: remoteDataTimestamp - 2 * 60 * 1000, + } as RemoteData); testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, + a: oldCachedSucceededData, b: remoteDataMocks.RequestPending, c: remoteDataMocks.ResponsePending, d: remoteDataMocks.Success, @@ -383,6 +390,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`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { @@ -411,17 +434,12 @@ describe('BaseDataService', () => { it('should link all the followLinks of a cached object by calling addDependency', () => { spyOn(objectCache, 'addDependency').and.callThrough(); 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, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, })); - const expected = '--b-c-d'; + const expected = 'a'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, + a: remoteDataMocks.Success, }; expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); @@ -570,11 +588,15 @@ describe('BaseDataService', () => { spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); }); - - it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + it('should not emit a cached completed RemoteData', () => { testScheduler.run(({ cold, expectObservable }) => { + // Old cached value from 1 minute before the test started + const oldCachedSucceededData: RemoteData = Object.assign({}, remoteDataPageMocks.Success, { + timeCompleted: remoteDataTimestamp - 2 * 60 * 1000, + lastUpdated: remoteDataTimestamp - 2 * 60 * 1000, + } as RemoteData); spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataPageMocks.Success, + a: oldCachedSucceededData, b: remoteDataPageMocks.RequestPending, c: remoteDataPageMocks.ResponsePending, d: remoteDataPageMocks.Success, @@ -592,6 +614,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`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index d09ee21ee0..979379e95b 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -285,6 +285,7 @@ export class BaseDataService implements HALDataServic map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), ); + const startTime: number = new Date().getTime(); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( @@ -292,7 +293,7 @@ export class BaseDataService implements HALDataServic // 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 // cached completed object - skipWhile((rd: RemoteData) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), + skipWhile((rd: RemoteData) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); @@ -338,6 +339,7 @@ export class BaseDataService implements HALDataServic map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), ); + const startTime: number = new Date().getTime(); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( @@ -345,7 +347,7 @@ export class BaseDataService implements HALDataServic // 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 // cached completed object - skipWhile((rd: RemoteData>) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), + skipWhile((rd: RemoteData>) => rd.isStale || (!useCachedVersionIfAvailable && rd.lastUpdated < startTime)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); diff --git a/src/app/core/data/dspace-rest-response-parsing.service.ts b/src/app/core/data/dspace-rest-response-parsing.service.ts index 0177a9813a..1cd286427f 100644 --- a/src/app/core/data/dspace-rest-response-parsing.service.ts +++ b/src/app/core/data/dspace-rest-response-parsing.service.ts @@ -120,6 +120,13 @@ export class DspaceRestResponseParsingService implements ResponseParsingService if (hasValue(match)) { embedAltUrl = new URLCombiner(embedAltUrl, `?size=${match.size}`).toString(); } + if (data._embedded[property] == null) { + // Embedded object is null, meaning it exists (not undefined), but had an empty response (204) -> cache it as null + this.addToObjectCache(null, request, data, embedAltUrl); + } else if (!isCacheableObject(data._embedded[property])) { + // Embedded object exists, but doesn't contain a self link -> cache it using the alternative link instead + this.objectCache.add(data._embedded[property], hasValue(request.responseMsToLive) ? request.responseMsToLive : environment.cache.msToLive.default, request.uuid, embedAltUrl); + } this.process(data._embedded[property], request, embedAltUrl); }); } @@ -237,7 +244,7 @@ export class DspaceRestResponseParsingService implements ResponseParsingService * @param alternativeURL an alternative url that can be used to retrieve the object */ addToObjectCache(co: CacheableObject, request: RestRequest, data: any, alternativeURL?: string): void { - if (!isCacheableObject(co)) { + if (hasValue(co) && !isCacheableObject(co)) { const type = hasValue(data) && hasValue(data.type) ? data.type : 'object'; let dataJSON: string; if (hasValue(data._embedded)) { @@ -251,7 +258,7 @@ export class DspaceRestResponseParsingService implements ResponseParsingService return; } - if (alternativeURL === co._links.self.href) { + if (hasValue(co) && alternativeURL === co._links.self.href) { alternativeURL = undefined; } diff --git a/src/app/core/data/object-updates/object-updates.service.stub.ts b/src/app/core/data/object-updates/object-updates.service.stub.ts new file mode 100644 index 0000000000..c41728a338 --- /dev/null +++ b/src/app/core/data/object-updates/object-updates.service.stub.ts @@ -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'); + +} diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 1fa30ae7e4..d4a6658f47 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -3,6 +3,8 @@ import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { of as observableOf } from 'rxjs'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment.test'; import { PAGINATED_RELATIONS_TO_ITEMS_OPERATOR } from '../../item-page/simple/item-types/shared/item-relationships-utils'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; @@ -150,6 +152,7 @@ describe('RelationshipDataService', () => { { provide: RequestService, useValue: requestService }, { provide: PAGINATED_RELATIONS_TO_ITEMS_OPERATOR, useValue: jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v) }, { provide: Store, useValue: provideMockStore() }, + { provide: APP_CONFIG, useValue: environment }, RelationshipDataService, ], }); @@ -157,7 +160,7 @@ describe('RelationshipDataService', () => { }); describe('composition', () => { - const initService = () => new RelationshipDataService(null, null, null, null, null, null, null, null); + const initService = () => new RelationshipDataService(null, null, null, null, null, null, null, null, environment); testSearchDataImplementation(initService); }); diff --git a/src/app/core/data/relationship-data.service.ts b/src/app/core/data/relationship-data.service.ts index 0ab9a0a3a2..d52c14f0a7 100644 --- a/src/app/core/data/relationship-data.service.ts +++ b/src/app/core/data/relationship-data.service.ts @@ -24,6 +24,10 @@ import { tap, } from 'rxjs/operators'; +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; import { AppState, keySelector, @@ -133,6 +137,7 @@ export class RelationshipDataService extends IdentifiableDataService, @Inject(PAGINATED_RELATIONS_TO_ITEMS_OPERATOR) private paginatedRelationsToItems: (thisId: string) => (source: Observable>>) => Observable>>, + @Inject(APP_CONFIG) private appConfig: AppConfig, ) { super('relationships', requestService, rdbService, objectCache, halService, 15 * 60 * 1000); @@ -319,7 +324,7 @@ export class RelationshipDataService extends IdentifiableDataService>> { - const linksToFollow: FollowLinkConfig[] = itemLinksToFollow(options.fetchThumbnail); + const linksToFollow: FollowLinkConfig[] = itemLinksToFollow(options.fetchThumbnail, this.appConfig.item.showAccessStatuses); linksToFollow.push(followLink('relationshipType')); return this.getItemRelationshipsByLabel(item, label, options, true, true, ...linksToFollow).pipe(this.paginatedRelationsToItems(item.uuid)); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index cc93e275ba..e15fcd3907 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -190,7 +190,7 @@ export class VersionHistoryDataService extends IdentifiableDataService) => { - if (versionRD.hasSucceeded && !versionRD.hasNoContent) { + if (versionRD.hasSucceeded && !versionRD.hasNoContent && hasValue(versionRD.payload)) { return versionRD.payload.versionhistory.pipe( getFirstCompletedRemoteData(), map((versionHistoryRD: RemoteData) => { diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index 2c3b9ad89b..8fc33145ff 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -45,7 +45,7 @@ export class UUIDIndexEffects { addObject$ = createEffect(() => this.actions$ .pipe( ofType(ObjectCacheActionTypes.ADD), - filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.uuid)), + filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache) && hasValue(action.payload.objectToCache.uuid)), map((action: AddToObjectCacheAction) => { return new AddToIndexAction( IndexName.OBJECT, @@ -64,7 +64,7 @@ export class UUIDIndexEffects { ofType(ObjectCacheActionTypes.ADD), map((action: AddToObjectCacheAction) => { const alternativeLink = action.payload.alternativeLink; - const selfLink = action.payload.objectToCache._links.self.href; + const selfLink = hasValue(action.payload.objectToCache?._links?.self) ? action.payload.objectToCache._links.self.href : alternativeLink; if (hasValue(alternativeLink) && alternativeLink !== selfLink) { return new AddToIndexAction( IndexName.ALTERNATIVE_OBJECT_LINK, diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 37f0d61656..78629f9d95 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -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 { Subscription } from '../shared/subscriptions/models/subscription.model'; 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 { ShortLivedToken } from './auth/models/short-lived-token.model'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; @@ -186,4 +187,5 @@ export const models = Itemfilter, SubmissionCoarNotifyConfig, NotifyRequestsStatus, + SystemWideAlert, ]; diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 6de81727fa..5fe5d02ecb 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -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 { CacheableObject } from '../cache/cacheable-object.model'; @@ -11,6 +14,9 @@ export abstract class BrowseDefinition extends CacheableObject { @autoserialize id: string; + @autoserializeAs('metadata') + metadataKeys: string[]; + /** * Get the render type of the BrowseDefinition model */ diff --git a/src/app/core/shared/hierarchical-browse-definition.model.ts b/src/app/core/shared/hierarchical-browse-definition.model.ts index e7c06a5372..eb606b7bbe 100644 --- a/src/app/core/shared/hierarchical-browse-definition.model.ts +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -1,6 +1,5 @@ import { autoserialize, - autoserializeAs, deserialize, inheritSerialization, } from 'cerialize'; @@ -33,9 +32,6 @@ export class HierarchicalBrowseDefinition extends BrowseDefinition { @autoserialize vocabulary: string; - @autoserializeAs('metadata') - metadataKeys: string[]; - get self(): string { return this._links.self.href; } diff --git a/src/app/core/shared/non-hierarchical-browse-definition.ts b/src/app/core/shared/non-hierarchical-browse-definition.ts index 7f8c6d0e5f..07083f7a8a 100644 --- a/src/app/core/shared/non-hierarchical-browse-definition.ts +++ b/src/app/core/shared/non-hierarchical-browse-definition.ts @@ -21,9 +21,6 @@ export abstract class NonHierarchicalBrowseDefinition extends BrowseDefinition { @autoserializeAs('order') defaultSortOrder: string; - @autoserializeAs('metadata') - metadataKeys: string[]; - @autoserialize dataType: BrowseByDataType; } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index 25b82b9b43..f3bbef3a4d 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -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 }">
{{ mdValue.newValue.value }}
- - - +
+ (click)="$event.stopPropagation();" + (keyup)="this.selectedValueLoading = false" + />
{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}
-
diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html index 4a6ffb412d..b161874fdf 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html @@ -26,13 +26,13 @@ - + ; - () + (); diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html index bd119493d3..8acdfed9b5 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html @@ -24,7 +24,7 @@ - + ; diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html index 34d5588c2d..65cf6fa1f8 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html @@ -32,7 +32,7 @@ - + diff --git a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html index 6f56056781..b78202d0fe 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html @@ -3,7 +3,7 @@ - + ; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html index e264958738..1f020d127f 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -11,11 +11,11 @@ - , + , - +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts index b2e62fcb37..cff764d744 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -33,7 +33,7 @@ import { OrgUnitInputSuggestionsComponent } from './org-unit-suggestions/org-uni @listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal) @listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants) @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'], templateUrl: './org-unit-search-result-list-submission-element.component.html', standalone: true, diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html index 66db99a6af..122c8bb59e 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -23,13 +23,13 @@ (clickSuggestion)="select($event)" (submitSuggestion)="selectCustom($event)"> - - - - + + + ; + + -
diff --git a/src/app/home-page/recent-item-list/recent-item-list.component.ts b/src/app/home-page/recent-item-list/recent-item-list.component.ts index 65c1998b7a..c7383dd883 100644 --- a/src/app/home-page/recent-item-list/recent-item-list.component.ts +++ b/src/app/home-page/recent-item-list/recent-item-list.component.ts @@ -98,6 +98,9 @@ export class RecentItemListComponent implements OnInit, OnDestroy { if (this.appConfig.browseBy.showThumbnails) { linksToFollow.push(followLink('thumbnail')); } + if (this.appConfig.item.showAccessStatuses) { + linksToFollow.push(followLink('accessStatus')); + } this.itemRD$ = this.searchService.search( new PaginatedSearchOptions({ diff --git a/src/app/item-page/alerts/item-alerts.component.html b/src/app/item-page/alerts/item-alerts.component.html index f6304340f3..b964327e39 100644 --- a/src/app/item-page/alerts/item-alerts.component.html +++ b/src/app/item-page/alerts/item-alerts.component.html @@ -8,7 +8,7 @@ {{'item.alerts.withdrawn' | translate}} diff --git a/src/app/item-page/alerts/item-alerts.component.spec.ts b/src/app/item-page/alerts/item-alerts.component.spec.ts index d2541f9d0d..ef788c47ec 100644 --- a/src/app/item-page/alerts/item-alerts.component.spec.ts +++ b/src/app/item-page/alerts/item-alerts.component.spec.ts @@ -161,7 +161,7 @@ describe('ItemAlertsComponent', () => { (authorizationService.isAuthorized).and.returnValue(isAdmin$); (correctionTypeDataService.findByItem).and.returnValue(correction$); - expectObservable(component.showReinstateButton$()).toBe(expectedMarble, expectedValues); + expectObservable(component.shouldShowReinstateButton()).toBe(expectedMarble, expectedValues); }); }); diff --git a/src/app/item-page/alerts/item-alerts.component.ts b/src/app/item-page/alerts/item-alerts.component.ts index 1984de0324..bcf940e69d 100644 --- a/src/app/item-page/alerts/item-alerts.component.ts +++ b/src/app/item-page/alerts/item-alerts.component.ts @@ -5,6 +5,8 @@ import { import { Component, Input, + OnChanges, + SimpleChanges, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; @@ -45,12 +47,17 @@ import { /** * Component displaying alerts for an item */ -export class ItemAlertsComponent { +export class ItemAlertsComponent implements OnChanges { /** * The Item to display alerts for */ @Input() item: Item; + /** + * Whether the reinstate button should be shown + */ + showReinstateButton$: Observable; + /** * The AlertType enumeration * @type {AlertType} @@ -58,18 +65,24 @@ export class ItemAlertsComponent { public AlertTypeEnum = AlertType; constructor( - private authService: AuthorizationDataService, - private dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService, - private correctionTypeDataService: CorrectionTypeDataService, + protected authService: AuthorizationDataService, + protected dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService, + protected correctionTypeDataService: CorrectionTypeDataService, ) { } + ngOnChanges(changes: SimpleChanges): void { + if (changes.item?.currentValue.withdrawn && this.showReinstateButton$) { + this.showReinstateButton$ = this.shouldShowReinstateButton(); + } + } + /** * Determines whether to show the reinstate button. * The button is shown if the user is not an admin and the item has a reinstate request. * @returns An Observable that emits a boolean value indicating whether to show the reinstate button. */ - showReinstateButton$(): Observable { + shouldShowReinstateButton(): Observable { const correction$ = this.correctionTypeDataService.findByItem(this.item.uuid, true).pipe( getFirstCompletedRemoteData(), map((correctionTypeRD: RemoteData>) => correctionTypeRD.hasSucceeded ? correctionTypeRD.payload.page : []), @@ -78,8 +91,8 @@ export class ItemAlertsComponent { return combineLatest([isAdmin$, correction$]).pipe( map(([isAdmin, correction]) => { return !isAdmin && correction.some((correctionType) => correctionType.topic === REQUEST_REINSTATE); - }, - )); + }), + ); } /** diff --git a/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index 3829b3286c..254c44ad46 100644 --- a/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -16,9 +16,9 @@ import { Subscription, } from 'rxjs'; import { - first, map, switchMap, + take, tap, } from 'rxjs/operators'; @@ -33,7 +33,7 @@ import { getAllSucceededRemoteData } from '../../../core/shared/operators'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../item.resolver'; +import { getItemPageLinksToFollow } from '../../item.resolver'; import { getItemPageRoute } from '../../item-page-routing-paths'; @Component({ @@ -92,7 +92,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl this.item = rd.payload; }), switchMap((rd: RemoteData) => { - return this.itemService.findByHref(rd.payload._links.self.href, true, true, ...ITEM_PAGE_LINKS_TO_FOLLOW); + return this.itemService.findByHref(rd.payload._links.self.href, true, true, ...getItemPageLinksToFollow()); }), getAllSucceededRemoteData(), ).subscribe((rd: RemoteData) => { @@ -102,7 +102,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl super.ngOnInit(); this.discardTimeOut = environment.item.edit.undoTimeout; - this.hasChanges().pipe(first()).subscribe((hasChanges) => { + this.hasChanges().pipe(take(1)).subscribe((hasChanges) => { if (!hasChanges) { this.initializeOriginalFields(); } else { @@ -187,7 +187,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl */ private checkLastModified() { const currentVersion = this.item.lastModified; - this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + this.objectUpdatesService.getLastModified(this.url).pipe(take(1)).subscribe( (updateVersion: Date) => { if (updateVersion.getDate() !== currentVersion.getDate()) { this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index 88d984c19f..9d8f384e16 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -1,4 +1,8 @@
+
+ +
+
-
-
-
- - {{'item.edit.bitstreams.headers.name' | translate}} -
-
{{'item.edit.bitstreams.headers.description' | translate}}
-
{{'item.edit.bitstreams.headers.format' | translate}}
-
{{'item.edit.bitstreams.headers.actions' | translate}}
-
- + + [isFirstTable]="isFirst" + aria-describedby="reorder-description">
+ + diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 7de575b785..7fd1f4b31e 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -1,23 +1,4 @@ -.header-row { - color: var(--bs-table-dark-color); - background-color: var(--bs-table-dark-bg); - border-color: var(--bs-table-dark-border-color); -} - -.bundle-row { - color: var(--bs-table-head-color); - background-color: var(--bs-table-head-bg); - border-color: var(--bs-table-border-color); -} - -.row-element { - padding: 12px; - padding: 0.75em; - border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); -} - .drag-handle { - visibility: hidden; &:hover { cursor: move; } @@ -27,10 +8,6 @@ cursor: move; } -:host ::ng-deep .bitstream-row:hover .drag-handle, :host ::ng-deep .bitstream-row-drag-handle:focus .drag-handle { - visibility: visible !important; -} - .cdk-drag-preview { margin-left: 0; box-sizing: border-box; @@ -54,3 +31,25 @@ :host ::ng-deep .larger-tooltip .tooltip-inner { max-width: 500px; } + +.table-border { + border: 1px solid #dee2e6; +} + +:host ::ng-deep .pagination { + padding-top: 0.5rem; +} + +.scrollable-table { + overflow-x: auto; +} + +.disabled-overlay { + opacity: 0.6; +} + +.loading-overlay { + position: fixed; + top: 50%; + left: 50%; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 6fcb2fab84..874107e6de 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -7,6 +7,7 @@ import { TestBed, waitForAsync, } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, Router, @@ -15,7 +16,6 @@ import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { RestResponse } from '../../../core/cache/response.models'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; import { BundleDataService } from '../../../core/data/bundle-data.service'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -44,8 +44,12 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe'; import { VarDirective } from '../../../shared/utils/var.directive'; import { ItemBitstreamsComponent } from './item-bitstreams.component'; +import { ItemBitstreamsService } from './item-bitstreams.service'; +import { + getItemBitstreamsServiceStub, + ItemBitstreamsServiceStub, +} from './item-bitstreams.service.stub'; import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle/item-edit-bitstream-bundle.component'; -import { ItemEditBitstreamDragHandleComponent } from './item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; let comp: ItemBitstreamsComponent; let fixture: ComponentFixture; @@ -97,6 +101,7 @@ let objectCache: ObjectCacheService; let requestService: RequestService; let searchConfig: SearchConfigurationService; let bundleService: BundleDataService; +let itemBitstreamsService: ItemBitstreamsServiceStub; describe('ItemBitstreamsComponent', () => { beforeEach(waitForAsync(() => { @@ -165,11 +170,19 @@ describe('ItemBitstreamsComponent', () => { url: url, }); bundleService = jasmine.createSpyObj('bundleService', { - patch: observableOf(new RestResponse(true, 200, 'OK')), + patch: createSuccessfulRemoteDataObject$({}), }); + itemBitstreamsService = getItemBitstreamsServiceStub(); + TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), ItemBitstreamsComponent, ObjectValuesPipe, VarDirective], + imports: [ + TranslateModule.forRoot(), + ItemBitstreamsComponent, + ObjectValuesPipe, + VarDirective, + BrowserAnimationsModule, + ], providers: [ { provide: ItemDataService, useValue: itemService }, { provide: ObjectUpdatesService, useValue: objectUpdatesService }, @@ -181,6 +194,7 @@ describe('ItemBitstreamsComponent', () => { { provide: RequestService, useValue: requestService }, { provide: SearchConfigurationService, useValue: searchConfig }, { provide: BundleDataService, useValue: bundleService }, + { provide: ItemBitstreamsService, useValue: itemBitstreamsService }, ChangeDetectorRef, ], schemas: [ NO_ERRORS_SCHEMA, @@ -189,7 +203,6 @@ describe('ItemBitstreamsComponent', () => { .overrideComponent(ItemBitstreamsComponent, { remove: { imports: [ItemEditBitstreamBundleComponent, - ItemEditBitstreamDragHandleComponent, ThemedLoadingComponent], }, }) @@ -209,28 +222,8 @@ describe('ItemBitstreamsComponent', () => { comp.submit(); }); - it('should call removeMultiple on the bitstreamService for the marked field', () => { - expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]); - }); - - it('should not call removeMultiple on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]); - }); - }); - - describe('when dropBitstream is called', () => { - beforeEach((done) => { - comp.dropBitstream(bundle, { - fromIndex: 0, - toIndex: 50, - finish: () => { - done(); - }, - }); - }); - - it('should send out a patch for the move operation', () => { - expect(bundleService.patch).toHaveBeenCalled(); + it('should call removeMarkedBitstreams on the itemBitstreamsService', () => { + expect(itemBitstreamsService.removeMarkedBitstreams).toHaveBeenCalled(); }); }); @@ -247,4 +240,114 @@ describe('ItemBitstreamsComponent', () => { expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self); }); }); + + describe('moveUp', () => { + it('should move the selected bitstream up', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveUp(event); + + expect(itemBitstreamsService.moveSelectedBitstreamUp).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveUp(event); + + expect(itemBitstreamsService.moveSelectedBitstreamUp).not.toHaveBeenCalled(); + }); + }); + + describe('moveDown', () => { + it('should move the selected bitstream down', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveDown(event); + + expect(itemBitstreamsService.moveSelectedBitstreamDown).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.moveDown(event); + + expect(itemBitstreamsService.moveSelectedBitstreamDown).not.toHaveBeenCalled(); + }); + }); + + describe('cancelSelection', () => { + it('should cancel the selection', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.cancelSelection(event); + + expect(itemBitstreamsService.cancelSelection).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + preventDefault: () => {/* Intentionally empty */}, + } as KeyboardEvent; + comp.cancelSelection(event); + + expect(itemBitstreamsService.cancelSelection).not.toHaveBeenCalled(); + }); + }); + + describe('clearSelection', () => { + it('should clear the selection', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + target: document.createElement('BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).toHaveBeenCalled(); + }); + + it('should not do anything if no bitstream is selected', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(false); + + const event = { + target: document.createElement('BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).not.toHaveBeenCalled(); + }); + + it('should not do anything if the event target is not \'BODY\'', () => { + itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); + + const event = { + target: document.createElement('NOT-BODY'), + preventDefault: () => {/* Intentionally empty */}, + } as unknown as KeyboardEvent; + comp.clearSelection(event); + + expect(itemBitstreamsService.clearSelection).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 685ad006e0..143723d447 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,11 +1,8 @@ -import { - AsyncPipe, - NgForOf, - NgIf, -} from '@angular/common'; +import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, + HostListener, NgZone, OnDestroy, } from '@angular/core'; @@ -18,15 +15,12 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; -import { Operation } from 'fast-json-patch'; import { combineLatest, Observable, Subscription, - zip as observableZip, } from 'rxjs'; import { - filter, map, switchMap, take, @@ -36,49 +30,40 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; import { BundleDataService } from '../../../core/data/bundle-data.service'; import { ItemDataService } from '../../../core/data/item-data.service'; -import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; -import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; -import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { RequestService } from '../../../core/data/request.service'; -import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bundle } from '../../../core/shared/bundle.model'; import { NoContent } from '../../../core/shared/NoContent.model'; import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '../../../core/shared/operators'; -import { - hasValue, - isNotEmpty, -} from '../../../shared/empty.util'; +import { AlertComponent } from '../../../shared/alert/alert.component'; +import { AlertType } from '../../../shared/alert/alert-type'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe'; import { VarDirective } from '../../../shared/utils/var.directive'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { ItemBitstreamsService } from './item-bitstreams.service'; import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle/item-edit-bitstream-bundle.component'; -import { ItemEditBitstreamDragHandleComponent } from './item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; @Component({ selector: 'ds-item-bitstreams', styleUrls: ['./item-bitstreams.component.scss'], templateUrl: './item-bitstreams.component.html', imports: [ - AsyncPipe, + CommonModule, TranslateModule, ItemEditBitstreamBundleComponent, RouterLink, - NgIf, VarDirective, - ItemEditBitstreamDragHandleComponent, - NgForOf, ThemedLoadingComponent, + AlertComponent, ], providers: [ObjectValuesPipe], standalone: true, @@ -88,33 +73,18 @@ import { ItemEditBitstreamDragHandleComponent } from './item-edit-bitstream-drag */ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy { + // Declared for use in template + protected readonly AlertType = AlertType; + /** * The currently listed bundles */ bundles$: Observable; - /** - * The page options to use for fetching the bundles - */ - bundlesOptions = { - id: 'bundles-pagination-options', - currentPage: 1, - pageSize: 9999, - } as any; - /** * The bootstrap sizes used for the columns within this table */ - columnSizes = new ResponsiveTableSizes([ - // Name column - new ResponsiveColumnSizes(2, 2, 3, 4, 4), - // Description column - new ResponsiveColumnSizes(2, 3, 3, 3, 3), - // Format column - new ResponsiveColumnSizes(2, 2, 2, 2, 2), - // Actions column - new ResponsiveColumnSizes(6, 5, 4, 3, 3), - ]); + columnSizes: ResponsiveTableSizes; /** * Are we currently submitting the changes? @@ -128,6 +98,11 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ itemUpdateSubscription: Subscription; + /** + * An observable which emits a boolean which represents whether the service is currently handling a 'move' request + */ + isProcessingMoveRequest: Observable; + constructor( public itemService: ItemDataService, public objectUpdatesService: ObjectUpdatesService, @@ -141,21 +116,82 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme public cdRef: ChangeDetectorRef, public bundleService: BundleDataService, public zone: NgZone, + public itemBitstreamsService: ItemBitstreamsService, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, route); + + this.columnSizes = this.itemBitstreamsService.getColumnSizes(); } /** * Actions to perform after the item has been initialized */ postItemInit(): void { - this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({ pagination: this.bundlesOptions })).pipe( + const bundlesOptions = this.itemBitstreamsService.getInitialBundlesPaginationOptions(); + this.isProcessingMoveRequest = this.itemBitstreamsService.getPerformingMoveRequest$(); + + this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({ pagination: bundlesOptions })).pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), map((bundlePage: PaginatedList) => bundlePage.page), ); } + /** + * Handles keyboard events that should move the currently selected bitstream up + */ + @HostListener('document:keydown.arrowUp', ['$event']) + moveUp(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.moveSelectedBitstreamUp(); + } + } + + /** + * Handles keyboard events that should move the currently selected bitstream down + */ + @HostListener('document:keydown.arrowDown', ['$event']) + moveDown(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.moveSelectedBitstreamDown(); + } + } + + /** + * Handles keyboard events that should cancel the currently selected bitstream. + * A cancel means that the selected bitstream is returned to its original position and is no longer selected. + * @param event + */ + @HostListener('document:keyup.escape', ['$event']) + cancelSelection(event: KeyboardEvent) { + if (this.itemBitstreamsService.hasSelectedBitstream()) { + event.preventDefault(); + this.itemBitstreamsService.cancelSelection(); + } + } + + /** + * Handles keyboard events that should clear the currently selected bitstream. + * A clear means that the selected bitstream remains in its current position but is no longer selected. + */ + @HostListener('document:keydown.enter', ['$event']) + @HostListener('document:keydown.space', ['$event']) + clearSelection(event: KeyboardEvent) { + // Only when no specific element is in focus do we want to clear the currently selected bitstream + // Otherwise we might clear the selection when a different action was intended, e.g. clicking a button or selecting + // a different bitstream. + if ( + this.itemBitstreamsService.hasSelectedBitstream() && + event.target instanceof Element && + event.target.tagName === 'BODY' + ) { + event.preventDefault(); + this.itemBitstreamsService.clearSelection(); + } + } + /** * Initialize the notification messages prefix */ @@ -171,84 +207,16 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ submit() { this.submitting = true; - const bundlesOnce$ = this.bundles$.pipe(take(1)); - // Fetch all removed bitstreams from the object update service - const removedBitstreams$ = bundlesOnce$.pipe( - switchMap((bundles: Bundle[]) => observableZip( - ...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true)), - )), - map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat( - ...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), - )), - map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field)), - ); - - // Send out delete requests for all deleted bitstreams - const removedResponses$: Observable> = removedBitstreams$.pipe( - take(1), - switchMap((removedBitstreams: Bitstream[]) => { - return this.bitstreamService.removeMultiple(removedBitstreams); - }), - ); + const removedResponses$ = this.itemBitstreamsService.removeMarkedBitstreams(this.bundles$); // Perform the setup actions from above in order and display notifications removedResponses$.subscribe((responses: RemoteData) => { - this.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); + this.itemBitstreamsService.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); this.submitting = false; }); } - /** - * A bitstream was dropped in a new location. Send out a Move Patch request to the REST API, display notifications, - * refresh the bundle's cache (so the lists can properly reload) and call the event's callback function (which will - * navigate the user to the correct page) - * @param bundle The bundle to send patch requests to - * @param event The event containing the index the bitstream came from and was dropped to - */ - dropBitstream(bundle: Bundle, event: any) { - this.zone.runOutsideAngular(() => { - if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { - const moveOperation = { - op: 'move', - from: `/_links/bitstreams/${event.fromIndex}/href`, - path: `/_links/bitstreams/${event.toIndex}/href`, - } as Operation; - this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RemoteData) => { - this.zone.run(() => { - this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); - // Remove all cached requests from this bundle and call the event's callback when the requests are cleared - this.requestService.removeByHrefSubstring(bundle.self).pipe( - filter((isCached) => isCached), - take(1), - ).subscribe(() => event.finish()); - }); - }); - } - }); - } - - /** - * Display notifications - * - Error notification for each failed response with their message - * - Success notification in case there's at least one successful response - * @param key The i18n key for the notification messages - * @param responses The returned responses to display notifications for - */ - displayNotifications(key: string, responses: RemoteData[]) { - if (isNotEmpty(responses)) { - const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); - const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); - - failedResponses.forEach((response: RemoteData) => { - this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage); - }); - if (successfulResponses.length > 0) { - this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`)); - } - } - } - /** * Request the object updates service to discard all current changes to this item * Shows a notification to remind the user that they can undo this diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts new file mode 100644 index 0000000000..490897b22a --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.spec.ts @@ -0,0 +1,712 @@ +import { + fakeAsync, + tick, +} from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ObjectUpdatesServiceStub } from '../../../core/data/object-updates/object-updates.service.stub'; +import { RequestService } from '../../../core/data/request.service'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { LiveRegionService } from '../../../shared/live-region/live-region.service'; +import { getLiveRegionServiceStub } from '../../../shared/live-region/live-region.service.stub'; +import { DSONameServiceMock } from '../../../shared/mocks/dso-name.service.mock'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../../shared/remote-data.utils'; +import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { + ItemBitstreamsService, + SelectedBitstreamTableEntry, +} from './item-bitstreams.service'; +import createSpy = jasmine.createSpy; +import { MoveOperation } from 'fast-json-patch'; + +describe('ItemBitstreamsService', () => { + let service: ItemBitstreamsService; + let notificationsService: NotificationsService; + let translateService: TranslateService; + let objectUpdatesService: ObjectUpdatesService; + let bitstreamDataService: BitstreamDataService; + let bundleDataService: BundleDataService; + let dsoNameService: DSONameService; + let requestService: RequestService; + let liveRegionService: LiveRegionService; + + beforeEach(() => { + notificationsService = new NotificationsServiceStub() as any; + translateService = getMockTranslateService(); + objectUpdatesService = new ObjectUpdatesServiceStub() as any; + bitstreamDataService = new BitstreamDataServiceStub() as any; + bundleDataService = jasmine.createSpyObj('bundleDataService', { + patch: createSuccessfulRemoteDataObject$(new Bundle()), + }); + dsoNameService = new DSONameServiceMock() as any; + requestService = jasmine.createSpyObj('requestService', { + setStaleByHrefSubstring: of(true), + }); + liveRegionService = getLiveRegionServiceStub(); + + service = new ItemBitstreamsService( + notificationsService, + translateService, + objectUpdatesService, + bitstreamDataService, + bundleDataService, + dsoNameService, + requestService, + liveRegionService, + ); + }); + + const defaultEntry: SelectedBitstreamTableEntry = { + bitstream: { + name: 'bitstream name', + } as any, + bundle: Object.assign(new Bundle(), { + _links: { self: { href: 'self_link' } }, + }), + bundleSize: 10, + currentPosition: 0, + originalPosition: 0, + }; + + describe('selectBitstreamEntry', () => { + it('should correctly make getSelectedBitstream$ emit', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + })); + + it('should correctly make getSelectedBitstream return the bitstream', () => { + expect(service.getSelectedBitstream()).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + expect(service.getSelectedBitstream()).toEqual(entry); + }); + + it('should correctly make hasSelectedBitstream return', () => { + expect(service.hasSelectedBitstream()).toBeFalse(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + expect(service.hasSelectedBitstream()).toBeTrue(); + }); + + it('should do nothing if no entry was provided', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.selectBitstreamEntry(null); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + })); + + it('should announce the selected bitstream', () => { + const entry = Object.assign({}, defaultEntry); + + spyOn(service, 'announceSelect'); + + service.selectBitstreamEntry(entry); + expect(service.announceSelect).toHaveBeenCalledWith(entry.bitstream.name); + }); + }); + + describe('clearSelection', () => { + it('should clear the selected bitstream', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.clearSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cleared', selectedEntry: entry }); + })); + + it('should not do anything if there is no selected bitstream', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + service.clearSelection(); + tick(); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + })); + + it('should announce the cleared bitstream', () => { + const entry = Object.assign({}, defaultEntry); + + spyOn(service, 'announceClear'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.announceClear).toHaveBeenCalledWith(entry.bitstream.name); + }); + + it('should display a notification if the selected bitstream was moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + }, + ); + + spyOn(service, 'displaySuccessNotification'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.displaySuccessNotification).toHaveBeenCalled(); + }); + + it('should not display a notification if the selected bitstream is in its original position', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + }, + ); + + spyOn(service, 'displaySuccessNotification'); + service.selectBitstreamEntry(entry); + service.clearSelection(); + + expect(service.displaySuccessNotification).not.toHaveBeenCalled(); + }); + }); + + describe('cancelSelection', () => { + it('should clear the selected bitstream if it has not moved', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.cancelSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cleared', selectedEntry: entry }); + })); + + it('should cancel the selected bitstream if it has moved', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const entry = Object.assign({}, defaultEntry, { + originalPosition: 0, + currentPosition: 3, + }); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.cancelSelection(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Cancelled', selectedEntry: entry }); + })); + + it('should announce a clear if the bitstream has not moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + }, + ); + + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.announceClear).toHaveBeenCalledWith(entry.bitstream.name); + expect(service.announceCancel).not.toHaveBeenCalled(); + }); + + it('should announce a cancel if the bitstream has moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + }, + ); + + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.announceClear).not.toHaveBeenCalled(); + expect(service.announceCancel).toHaveBeenCalledWith(entry.bitstream.name, entry.originalPosition); + }); + + it('should return the bitstream to its original position if it has moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 7, + }, + ); + + spyOn(service, 'performBitstreamMoveRequest'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, entry.currentPosition, entry.originalPosition); + }); + + it('should not move the bitstream if it has not moved', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 7, + currentPosition: 7, + }, + ); + + spyOn(service, 'performBitstreamMoveRequest'); + + service.selectBitstreamEntry(entry); + service.cancelSelection(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + spyOn(service, 'announceClear'); + spyOn(service, 'announceCancel'); + spyOn(service, 'performBitstreamMoveRequest'); + + service.cancelSelection(); + + expect(service.announceClear).not.toHaveBeenCalled(); + expect(service.announceCancel).not.toHaveBeenCalled(); + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + + describe('moveSelectedBitstream', () => { + beforeEach(() => { + spyOn(service, 'performBitstreamMoveRequest').and.callThrough(); + }); + + describe('up', () => { + it('should move the selected bitstream one position up', () => { + const startPosition = 7; + const endPosition = startPosition - 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, startPosition, endPosition, jasmine.any(Function)); + expect(service.getSelectedBitstream()).toEqual(movedEntry); + }); + + it('should emit the move', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const startPosition = 7; + const endPosition = startPosition - 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + }, + ); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.moveSelectedBitstreamUp(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Moved', selectedEntry: movedEntry }); + })); + + it('should announce the move', () => { + const startPosition = 7; + const endPosition = startPosition - 1; + + spyOn(service, 'announceMove'); + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + + expect(service.announceMove).toHaveBeenCalledWith(entry.bitstream.name, endPosition); + }); + + it('should not do anything if the bitstream is already at the top', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 0, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamUp(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + service.moveSelectedBitstreamUp(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + + describe('down', () => { + it('should move the selected bitstream one position down', () => { + const startPosition = 7; + const endPosition = startPosition + 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + expect(service.performBitstreamMoveRequest).toHaveBeenCalledWith(entry.bundle, startPosition, endPosition, jasmine.any(Function)); + expect(service.getSelectedBitstream()).toEqual(movedEntry); + }); + + it('should emit the move', fakeAsync(() => { + const emittedActions = []; + + service.getSelectionAction$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeNull(); + + const startPosition = 7; + const endPosition = startPosition + 1; + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + const movedEntry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: endPosition, + }, + ); + + service.selectBitstreamEntry(entry); + tick(); + + expect(emittedActions.length).toBe(2); + expect(emittedActions[1]).toEqual({ action: 'Selected', selectedEntry: entry }); + + service.moveSelectedBitstreamDown(); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[2]).toEqual({ action: 'Moved', selectedEntry: movedEntry }); + })); + + it('should announce the move', () => { + const startPosition = 7; + const endPosition = startPosition + 1; + + spyOn(service, 'announceMove'); + + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: startPosition, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + + expect(service.announceMove).toHaveBeenCalledWith(entry.bitstream.name, endPosition); + }); + + it('should not do anything if the bitstream is already at the bottom of the bundle', () => { + const entry = Object.assign({}, defaultEntry, + { + originalPosition: 5, + currentPosition: 9, + }, + ); + + service.selectBitstreamEntry(entry); + service.moveSelectedBitstreamDown(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should not do anything if there is no selected bitstream', () => { + service.moveSelectedBitstreamDown(); + + expect(service.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + }); + }); + + describe('performBitstreamMoveRequest', () => { + const bundle: Bundle = defaultEntry.bundle; + const from = 5; + const to = 7; + const callback = createSpy('callbackFunction'); + + it('should correctly create the Move request', () => { + const expectedOperation: MoveOperation = { + op: 'move', + from: `/_links/bitstreams/${from}/href`, + path: `/_links/bitstreams/${to}/href`, + }; + + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(bundleDataService.patch).toHaveBeenCalledWith(bundle, [expectedOperation]); + }); + + it('should correctly make the bundle\'s self link stale', () => { + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith(bundle._links.self.href); + }); + + it('should attempt to show a message should the request have failed', () => { + spyOn(service, 'displayFailedResponseNotifications'); + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(service.displayFailedResponseNotifications).toHaveBeenCalled(); + }); + + it('should correctly call the provided function once the request has finished', () => { + service.performBitstreamMoveRequest(bundle, from, to, callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should emit at the start and end of the request', fakeAsync(() => { + const emittedActions = []; + + service.getPerformingMoveRequest$().subscribe(selected => emittedActions.push(selected)); + + expect(emittedActions.length).toBe(1); + expect(emittedActions[0]).toBeFalse(); + + service.performBitstreamMoveRequest(bundle, from, to, callback); + tick(); + + expect(emittedActions.length).toBe(3); + expect(emittedActions[1]).toBeTrue(); + expect(emittedActions[2]).toBeFalse(); + })); + }); + + describe('displayNotifications', () => { + it('should display an error notification if a response failed', () => { + const responses = [ + createFailedRemoteDataObject(), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.failed.title'); + }); + + it('should display a success notification if a response succeeded', () => { + const responses = [ + createSuccessfulRemoteDataObject(undefined), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(notificationsService.error).not.toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + }); + + it('should display both notifications if some failed and some succeeded', () => { + const responses = [ + createFailedRemoteDataObject(), + createSuccessfulRemoteDataObject(undefined), + ]; + + const key = 'some.key'; + + service.displayNotifications(key, responses); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + expect(translateService.instant).toHaveBeenCalledWith('some.key.saved.title'); + }); + }); + + describe('mapBitstreamsToTableEntries', () => { + it('should correctly map a Bitstream to a BitstreamTableEntry', () => { + const format: BitstreamFormat = new BitstreamFormat(); + + const bitstream: Bitstream = Object.assign(new Bitstream(), { + uuid: 'testUUID', + format: createSuccessfulRemoteDataObject$(format), + }); + + spyOn(dsoNameService, 'getName').and.returnValue('Test Name'); + spyOn(bitstream, 'firstMetadataValue').and.returnValue('description'); + + const tableEntry = service.mapBitstreamsToTableEntries([bitstream])[0]; + + expect(tableEntry.name).toEqual('Test Name'); + expect(tableEntry.nameStripped).toEqual('TestName'); + expect(tableEntry.bitstream).toBe(bitstream); + expect(tableEntry.id).toEqual('testUUID'); + expect(tableEntry.description).toEqual('description'); + expect(tableEntry.downloadUrl).toEqual('/bitstreams/testUUID/download'); + }); + }); + + describe('nameToHeader', () => { + it('should correctly transform a string to an appropriate header ID', () => { + const stringA = 'Test String'; + const stringAResult = 'TestString'; + expect(service.nameToHeader(stringA)).toEqual(stringAResult); + + const stringB = 'Test String Two'; + const stringBResult = 'TestStringTwo'; + expect(service.nameToHeader(stringB)).toEqual(stringBResult); + + const stringC = 'Test String Three'; + const stringCResult = 'TestStringThree'; + expect(service.nameToHeader(stringC)).toEqual(stringCResult); + }); + }); + +}); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts new file mode 100644 index 0000000000..eb99c214ca --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts @@ -0,0 +1,79 @@ +import { of } from 'rxjs'; + +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; + +export function getItemBitstreamsServiceStub(): ItemBitstreamsServiceStub { + return new ItemBitstreamsServiceStub(); +} + +export class ItemBitstreamsServiceStub { + getSelectionAction$ = jasmine.createSpy('getSelectedBitstream$').and + .returnValue(of(null)); + + getSelectedBitstream = jasmine.createSpy('getSelectedBitstream').and + .returnValue(null); + + hasSelectedBitstream = jasmine.createSpy('hasSelectedBitstream').and + .returnValue(false); + + selectBitstreamEntry = jasmine.createSpy('selectBitstreamEntry'); + + clearSelection = jasmine.createSpy('clearSelection'); + + cancelSelection = jasmine.createSpy('cancelSelection'); + + moveSelectedBitstreamUp = jasmine.createSpy('moveSelectedBitstreamUp'); + + moveSelectedBitstreamDown = jasmine.createSpy('moveSelectedBitstreamDown'); + + performBitstreamMoveRequest = jasmine.createSpy('performBitstreamMoveRequest'); + + getPerformingMoveRequest = jasmine.createSpy('getPerformingMoveRequest').and.returnValue(false); + + getPerformingMoveRequest$ = jasmine.createSpy('getPerformingMoveRequest$').and.returnValue(of(false)); + + getInitialBundlesPaginationOptions = jasmine.createSpy('getInitialBundlesPaginationOptions').and + .returnValue(new PaginationComponentOptions()); + + getInitialBitstreamsPaginationOptions = jasmine.createSpy('getInitialBitstreamsPaginationOptions').and + .returnValue(new PaginationComponentOptions()); + + getColumnSizes = jasmine.createSpy('getColumnSizes').and + .returnValue( + new ResponsiveTableSizes([ + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + new ResponsiveColumnSizes(6, 5, 4, 3, 3), + ]), + ); + + displayNotifications = jasmine.createSpy('displayNotifications'); + + displayFailedResponseNotifications = jasmine.createSpy('displayFailedResponseNotifications'); + + displayErrorNotification = jasmine.createSpy('displayErrorNotification'); + + displaySuccessFulResponseNotifications = jasmine.createSpy('displaySuccessFulResponseNotifications'); + + displaySuccessNotification = jasmine.createSpy('displaySuccessNotification'); + + removeMarkedBitstreams = jasmine.createSpy('removeMarkedBitstreams').and + .returnValue(createSuccessfulRemoteDataObject$({})); + + mapBitstreamsToTableEntries = jasmine.createSpy('mapBitstreamsToTableEntries').and + .returnValue([]); + + nameToHeader = jasmine.createSpy('nameToHeader').and.returnValue('header'); + + stripWhiteSpace = jasmine.createSpy('stripWhiteSpace').and.returnValue('string'); + + announceSelect = jasmine.createSpy('announceSelect'); + + announceMove = jasmine.createSpy('announceMove'); + + announceCancel = jasmine.createSpy('announceCancel'); +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts new file mode 100644 index 0000000000..2221e037fb --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts @@ -0,0 +1,568 @@ +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { MoveOperation } from 'fast-json-patch'; +import { + BehaviorSubject, + Observable, + zip as observableZip, +} from 'rxjs'; +import { + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { getBitstreamDownloadRoute } from '../../../app-routing-paths'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { FieldUpdate } from '../../../core/data/object-updates/field-update.model'; +import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; +import { LiveRegionService } from '../../../shared/live-region/live-region.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; + +export const MOVE_KEY = 'item.edit.bitstreams.notifications.move'; + +/** + * Interface storing all the information necessary to create a row in the bitstream edit table + */ +export interface BitstreamTableEntry { + /** + * The bitstream + */ + bitstream: Bitstream, + /** + * The uuid of the Bitstream + */ + id: string, + /** + * The name of the Bitstream + */ + name: string, + /** + * The name of the Bitstream with all whitespace removed + */ + nameStripped: string, + /** + * The description of the Bitstream + */ + description: string, + /** + * Observable emitting the Format of the Bitstream + */ + format: Observable, + /** + * The download url of the Bitstream + */ + downloadUrl: string, +} + +/** + * Interface storing information necessary to highlight and reorder the selected bitstream entry + */ +export interface SelectedBitstreamTableEntry { + /** + * The selected entry + */ + bitstream: BitstreamTableEntry, + /** + * The bundle the bitstream belongs to + */ + bundle: Bundle, + /** + * The total number of bitstreams in the bundle + */ + bundleSize: number, + /** + * The original position of the bitstream within the bundle. + */ + originalPosition: number, + /** + * The current position of the bitstream within the bundle. + */ + currentPosition: number, +} + +/** + * Interface storing data regarding a change in selected bitstream + */ +export interface SelectionAction { + /** + * The different types of actions: + * - Selected: Bitstream was selected + * - Moved: Bitstream was moved + * - Cleared: Selection was cleared, bitstream remains at its current position + * - Cancelled: Selection was cancelled, bitstream returns to its original position + */ + action: 'Selected' | 'Moved' | 'Cleared' | 'Cancelled' + /** + * The table entry to which the selection action applies + */ + selectedEntry: SelectedBitstreamTableEntry, +} + +/** + * This service handles the selection and updating of the bitstreams and their order on the + * 'Edit Item' -> 'Bitstreams' page. + */ +@Injectable( + { providedIn: 'root' }, +) +export class ItemBitstreamsService { + + /** + * BehaviorSubject which emits every time the selected bitstream changes. + */ + protected selectionAction$: BehaviorSubject = new BehaviorSubject(null); + + protected isPerformingMoveRequest: BehaviorSubject = new BehaviorSubject(false); + + constructor( + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + protected objectUpdatesService: ObjectUpdatesService, + protected bitstreamService: BitstreamDataService, + protected bundleService: BundleDataService, + protected dsoNameService: DSONameService, + protected requestService: RequestService, + protected liveRegionService: LiveRegionService, + ) { + } + + /** + * Returns the observable emitting the selection actions + */ + getSelectionAction$(): Observable { + return this.selectionAction$; + } + + /** + * Returns the latest selection action + */ + getSelectionAction(): SelectionAction { + const action = this.selectionAction$.value; + + if (hasNoValue(action)) { + return null; + } + + return Object.assign({}, action); + } + + /** + * Returns true if there currently is a selected bitstream + */ + hasSelectedBitstream(): boolean { + const selectionAction = this.getSelectionAction(); + + if (hasNoValue(selectionAction)) { + return false; + } + + const action = selectionAction.action; + + return action === 'Selected' || action === 'Moved'; + } + + /** + * Returns a copy of the currently selected bitstream + */ + getSelectedBitstream(): SelectedBitstreamTableEntry { + if (!this.hasSelectedBitstream()) { + return null; + } + + const selectionAction = this.getSelectionAction(); + return Object.assign({}, selectionAction.selectedEntry); + } + + /** + * Select the provided entry + */ + selectBitstreamEntry(entry: SelectedBitstreamTableEntry) { + if (hasValue(entry) && entry.bitstream !== this.getSelectedBitstream()?.bitstream) { + this.announceSelect(entry.bitstream.name); + this.updateSelectionAction({ action: 'Selected', selectedEntry: entry }); + } + } + + /** + * Makes the {@link selectionAction$} observable emit the provided {@link SelectedBitstreamTableEntry}. + * @protected + */ + protected updateSelectionAction(action: SelectionAction) { + this.selectionAction$.next(action); + } + + /** + * Unselects the selected bitstream. Does nothing if no bitstream is selected. + */ + clearSelection() { + const selected = this.getSelectedBitstream(); + + if (hasValue(selected)) { + this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected }); + this.announceClear(selected.bitstream.name); + + if (selected.currentPosition !== selected.originalPosition) { + this.displaySuccessNotification(MOVE_KEY); + } + } + } + + /** + * Returns the currently selected bitstream to its original position and unselects the bitstream. + * Does nothing if no bitstream is selected. + */ + cancelSelection() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + + const originalPosition = selected.originalPosition; + const currentPosition = selected.currentPosition; + + // If the selected bitstream has not moved, there is no need to return it to its original position + if (currentPosition === originalPosition) { + this.announceClear(selected.bitstream.name); + this.updateSelectionAction({ action: 'Cleared', selectedEntry: selected }); + } else { + this.announceCancel(selected.bitstream.name, originalPosition); + this.performBitstreamMoveRequest(selected.bundle, currentPosition, originalPosition); + this.updateSelectionAction({ action: 'Cancelled', selectedEntry: selected }); + } + } + + /** + * Moves the selected bitstream one position up in the bundle. Does nothing if no bitstream is selected or the + * selected bitstream already is at the beginning of the bundle. + */ + moveSelectedBitstreamUp() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + const originalPosition = selected.currentPosition; + if (originalPosition > 0) { + const newPosition = originalPosition - 1; + selected.currentPosition = newPosition; + + const onRequestCompleted = () => { + this.announceMove(selected.bitstream.name, newPosition); + }; + + this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted); + this.updateSelectionAction({ action: 'Moved', selectedEntry: selected }); + } + } + + /** + * Moves the selected bitstream one position down in the bundle. Does nothing if no bitstream is selected or the + * selected bitstream already is at the end of the bundle. + */ + moveSelectedBitstreamDown() { + const selected = this.getSelectedBitstream(); + + if (hasNoValue(selected) || this.getPerformingMoveRequest()) { + return; + } + + const originalPosition = selected.currentPosition; + if (originalPosition < selected.bundleSize - 1) { + const newPosition = originalPosition + 1; + selected.currentPosition = newPosition; + + const onRequestCompleted = () => { + this.announceMove(selected.bitstream.name, newPosition); + }; + + this.performBitstreamMoveRequest(selected.bundle, originalPosition, newPosition, onRequestCompleted); + this.updateSelectionAction({ action: 'Moved', selectedEntry: selected }); + } + } + + /** + * Sends out a Move Patch request to the REST API, display notifications, + * refresh the bundle's cache (so the lists can properly reload) + * @param bundle The bundle to send patch requests to + * @param fromIndex The index to move from + * @param toIndex The index to move to + * @param finish Optional: Function to execute once the response has been received + */ + performBitstreamMoveRequest(bundle: Bundle, fromIndex: number, toIndex: number, finish?: () => void) { + if (this.getPerformingMoveRequest()) { + console.warn('Attempted to perform move request while previous request has not completed yet'); + return; + } + + const moveOperation: MoveOperation = { + op: 'move', + from: `/_links/bitstreams/${fromIndex}/href`, + path: `/_links/bitstreams/${toIndex}/href`, + }; + + this.announceLoading(); + this.isPerformingMoveRequest.next(true); + this.bundleService.patch(bundle, [moveOperation]).pipe( + getFirstCompletedRemoteData(), + tap((response: RemoteData) => this.displayFailedResponseNotifications(MOVE_KEY, [response])), + switchMap(() => this.requestService.setStaleByHrefSubstring(bundle.self)), + take(1), + ).subscribe(() => { + this.isPerformingMoveRequest.next(false); + finish?.(); + }); + } + + /** + * Whether the service currently is processing a 'move' request + */ + getPerformingMoveRequest(): boolean { + return this.isPerformingMoveRequest.value; + } + + /** + * Returns an observable which emits when the service starts, or ends, processing a 'move' request + */ + getPerformingMoveRequest$(): Observable { + return this.isPerformingMoveRequest; + } + + /** + * Returns the pagination options to use when fetching the bundles + */ + getInitialBundlesPaginationOptions(): PaginationComponentOptions { + return Object.assign(new PaginationComponentOptions(), { + id: 'bundles-pagination-options', + currentPage: 1, + pageSize: 9999, + }); + } + + /** + * Returns the initial pagination options to use when fetching the bitstreams + * @param bundleName The name of the bundle, will be as pagination id. + */ + getInitialBitstreamsPaginationOptions(bundleName: string): PaginationComponentOptions { + return Object.assign(new PaginationComponentOptions(),{ + id: bundleName, // This might behave unexpectedly if the item contains two bundles with the same name + currentPage: 1, + pageSize: 10, + }); + } + + /** + * Returns the {@link ResponsiveTableSizes} for use in the columns of the edit bitstreams table + */ + getColumnSizes(): ResponsiveTableSizes { + return new ResponsiveTableSizes([ + // Name column + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + // Description column + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + // Format column + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + // Actions column + new ResponsiveColumnSizes(6, 5, 4, 3, 3), + ]); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displayNotifications(key: string, responses: RemoteData[]) { + this.displayFailedResponseNotifications(key, responses); + this.displaySuccessFulResponseNotifications(key, responses); + } + + /** + * Display an error notification for each failed response with their message + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displayFailedResponseNotifications(key: string, responses: RemoteData[]) { + const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); + failedResponses.forEach((response: RemoteData) => { + this.displayErrorNotification(key, response.errorMessage); + }); + } + + /** + * Display an error notification with the provided key and message + * @param key The i18n key for the notification messages + * @param errorMessage The error message to display + */ + displayErrorNotification(key: string, errorMessage: string) { + this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), errorMessage); + } + + /** + * Display a success notification in case there's at least one successful response + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displaySuccessFulResponseNotifications(key: string, responses: RemoteData[]) { + const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); + if (successfulResponses.length > 0) { + this.displaySuccessNotification(key); + } + } + + /** + * Display a success notification with the provided key + * @param key The i18n key for the notification messages + */ + displaySuccessNotification(key: string) { + this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`)); + } + + /** + * Removes the bitstreams marked for deletion from the Bundles emitted by the provided observable. + * @param bundles$ + */ + removeMarkedBitstreams(bundles$: Observable): Observable> { + const bundlesOnce$ = bundles$.pipe(take(1)); + + // Fetch all removed bitstreams from the object update service + const removedBitstreams$ = bundlesOnce$.pipe( + switchMap((bundles: Bundle[]) => observableZip( + ...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true)), + )), + map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat( + ...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + )), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field)), + ); + + // Send out delete requests for all deleted bitstreams + return removedBitstreams$.pipe( + take(1), + switchMap((removedBitstreams: Bitstream[]) => { + return this.bitstreamService.removeMultiple(removedBitstreams); + }), + ); + } + + /** + * Creates an array of {@link BitstreamTableEntry}s from an array of {@link Bitstream}s + * @param bitstreams The bitstreams array to map to table entries + */ + mapBitstreamsToTableEntries(bitstreams: Bitstream[]): BitstreamTableEntry[] { + return bitstreams.map((bitstream) => { + const name = this.dsoNameService.getName(bitstream); + + return { + bitstream: bitstream, + id: bitstream.uuid, + name: name, + nameStripped: this.nameToHeader(name), + description: bitstream.firstMetadataValue('dc.description'), + format: bitstream.format.pipe(getFirstSucceededRemoteDataPayload()), + downloadUrl: getBitstreamDownloadRoute(bitstream), + }; + }); + } + + /** + * Returns a string appropriate to be used as header ID + * @param name + */ + nameToHeader(name: string): string { + // Whitespace is stripped from the Bitstream names for accessibility reasons. + // To make it clear which headers are relevant for a specific field in the table, the 'headers' attribute is used to + // refer to specific headers. The Bitstream's name is used as header ID for the row containing information regarding + // that bitstream. As the 'headers' attribute contains a space-separated string of header IDs, the Bitstream's header + // ID can not contain spaces itself. + return this.stripWhiteSpace(name); + } + + /** + * Returns a string equal to the input string with all whitespace removed. + * @param str + */ + stripWhiteSpace(str: string): string { + // '/\s+/g' matches all occurrences of any amount of whitespace characters + return str.replace(/\s+/g, ''); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name was selected. + * @param bitstreamName The name of the bitstream that was selected. + */ + announceSelect(bitstreamName: string) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.select', + { bitstream: bitstreamName }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name was moved to the provided + * position. + * @param bitstreamName The name of the bitstream that moved. + * @param toPosition The zero-indexed position that the bitstream moved to. + */ + announceMove(bitstreamName: string, toPosition: number) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.move', + { bitstream: bitstreamName, toIndex: toPosition + 1 }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected and + * was returned to the provided position. + * @param bitstreamName The name of the bitstream that is no longer selected + * @param toPosition The zero-indexed position the bitstream returned to. + */ + announceCancel(bitstreamName: string, toPosition: number) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.cancel', + { bitstream: bitstreamName, toIndex: toPosition + 1 }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the bitstream with the provided name is no longer selected. + * @param bitstreamName The name of the bitstream that is no longer selected. + */ + announceClear(bitstreamName: string) { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.clear', + { bitstream: bitstreamName }); + this.liveRegionService.addMessage(message); + } + + /** + * Adds a message to the live region mentioning that the + */ + announceLoading() { + const message = this.translateService.instant('item.edit.bitstreams.edit.live.loading'); + this.liveRegionService.addMessage(message); + } +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 42b9b9eb64..06201b1cbe 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -1,22 +1,140 @@ -
-
- -
- {{'item.edit.bitstreams.bundle.name' | translate:{ name: dsoNameService.getName(bundle) } }} -
-
-
-
- -
-
-
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{'item.edit.bitstreams.headers.name' | translate}} + + {{'item.edit.bitstreams.headers.description' | translate}} + + {{'item.edit.bitstreams.headers.format' | translate}} + + {{'item.edit.bitstreams.headers.actions' | translate}} +
+ {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundleName } }} + + +
+ +
+ + + +
+
+ +
+
+ +
+ {{ entry.name }} +
+ {{ entry.description }} + + {{ (entry.format | async)?.shortDescription }} + +
+
+ + + + + + +
+
+
+ +
+
+
diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss new file mode 100644 index 0000000000..bbd4e1e75c --- /dev/null +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.scss @@ -0,0 +1,24 @@ +.header-row { + color: var(--bs-table-dark-color); + background-color: var(--bs-table-dark-bg); + border-color: var(--bs-table-dark-bg); +} + +.bundle-row { + color: var(--bs-table-head-color); + background-color: var(--bs-table-head-bg); + border-color: var(--bs-table-border-color); +} + +.row-element { + padding: 0.75em; + border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); +} + +.bitstream-name { + font-weight: normal; +} + +.table { + margin-bottom: 0; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts index d39f9eb265..bd99b29c47 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts @@ -1,3 +1,4 @@ +import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { NO_ERRORS_SCHEMA, ViewContainerRef, @@ -8,11 +9,35 @@ import { waitForAsync, } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { + of as observableOf, + of, + Subject, +} from 'rxjs'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; +import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { Bundle } from '../../../../core/shared/bundle.model'; import { Item } from '../../../../core/shared/item.model'; +import { getMockRequestService } from '../../../../shared/mocks/request.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; +import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { + BitstreamTableEntry, + ItemBitstreamsService, + SelectedBitstreamTableEntry, +} from '../item-bitstreams.service'; +import { + getItemBitstreamsServiceStub, + ItemBitstreamsServiceStub, +} from '../item-bitstreams.service.stub'; import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle.component'; describe('ItemEditBitstreamBundleComponent', () => { @@ -39,9 +64,32 @@ describe('ItemEditBitstreamBundleComponent', () => { }, }); + const restEndpoint = 'fake-rest-endpoint'; + const bundleService = jasmine.createSpyObj('bundleService', { + getBitstreamsEndpoint: observableOf(restEndpoint), + getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([])), + }); + + let objectUpdatesService: any; + let itemBitstreamsService: ItemBitstreamsServiceStub; + beforeEach(waitForAsync(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + initialize: undefined, + getFieldUpdatesExclusive: of(null), + }); + + itemBitstreamsService = getItemBitstreamsServiceStub(); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), ItemEditBitstreamBundleComponent], + providers: [ + { provide: BundleDataService, useValue: bundleService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: PaginationService, useValue: new PaginationServiceStub() }, + { provide: RequestService, useValue: getMockRequestService() }, + { provide: ItemBitstreamsService, useValue: itemBitstreamsService }, + ], schemas: [ NO_ERRORS_SCHEMA, ], @@ -62,4 +110,251 @@ describe('ItemEditBitstreamBundleComponent', () => { it('should create an embedded view of the component', () => { expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled(); }); + + describe('on selected entry change', () => { + let paginationComponent: any; + let testSubject: Subject = new Subject(); + + beforeEach(() => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }); + comp.paginationComponent = paginationComponent; + + spyOn(comp, 'getCurrentPageSize').and.returnValue(2); + }); + + it('should move to the page the selected entry is on if were not on that page', () => { + + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 1, + currentPosition: 2, + }; + + comp.handleSelectionAction({ action: 'Moved', selectedEntry: entry }); + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(2); + }); + + it('should not change page when we are already on the correct page', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 0, + currentPosition: 1, + }; + + comp.handleSelectionAction({ action: 'Moved', selectedEntry: entry }); + expect(paginationComponent.doPageChange).not.toHaveBeenCalled(); + }); + + it('should change to the original page when cancelling', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 3, + currentPosition: 0, + }; + + comp.handleSelectionAction({ action: 'Cancelled', selectedEntry: entry }); + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(2); + }); + + it('should not change page when we are already on the correct page when cancelling', () => { + const entry: SelectedBitstreamTableEntry = { + bitstream: null, + bundle: bundle, + bundleSize: 5, + originalPosition: 0, + currentPosition: 3, + }; + + comp.handleSelectionAction({ action: 'Cancelled', selectedEntry: entry }); + expect(paginationComponent.doPageChange).not.toHaveBeenCalled(); + }); + }); + + describe('getRowClass', () => { + it('should return \'table-info\' when the bitstream is the selected bitstream', () => { + itemBitstreamsService.getSelectedBitstream.and.returnValue({ + bitstream: { id: 'bitstream-id' }, + }); + + const bitstreamEntry = { + id: 'bitstream-id', + } as BitstreamTableEntry; + + expect(comp.getRowClass(undefined, bitstreamEntry)).toEqual('table-info'); + }); + + it('should return \'table-warning\' when the update is of type \'UPDATE\'', () => { + const update = { + changeType: FieldChangeType.UPDATE, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-warning'); + }); + + it('should return \'table-success\' when the update is of type \'ADD\'', () => { + const update = { + changeType: FieldChangeType.ADD, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-success'); + }); + + it('should return \'table-danger\' when the update is of type \'REMOVE\'', () => { + const update = { + changeType: FieldChangeType.REMOVE, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('table-danger'); + }); + + it('should return \'bg-white\' in any other case', () => { + const update = { + changeType: undefined, + } as FieldUpdate; + + expect(comp.getRowClass(update, undefined)).toEqual('bg-white'); + }); + }); + + describe('drag', () => { + let dragTooltip; + let paginationComponent; + + beforeEach(() => { + dragTooltip = jasmine.createSpyObj('dragTooltip', { + open: undefined, + close: undefined, + }); + comp.dragTooltip = dragTooltip; + }); + + describe('Start', () => { + it('should open the tooltip when there are multiple pages', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(true), + }); + comp.paginationComponent = paginationComponent; + + comp.dragStart(); + expect(dragTooltip.open).toHaveBeenCalled(); + }); + + it('should not open the tooltip when there is only a single page', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(false), + }); + comp.paginationComponent = paginationComponent; + + comp.dragStart(); + expect(dragTooltip.open).not.toHaveBeenCalled(); + }); + }); + + describe('end', () => { + it('should always close the tooltip', () => { + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: undefined, + }, { + shouldShowBottomPager: of(false), + }); + comp.paginationComponent = paginationComponent; + + comp.dragEnd(); + expect(dragTooltip.close).toHaveBeenCalled(); + }); + }); + }); + + describe('drop', () => { + it('should correctly move the bitstream on drop', () => { + const event = { + previousIndex: 1, + currentIndex: 8, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).toHaveBeenCalledWith(jasmine.any(Bundle), 1, 8, jasmine.any(Function)); + }); + + it('should not move the bitstream if dropped in the same place', () => { + const event = { + previousIndex: 1, + currentIndex: 1, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).not.toHaveBeenCalled(); + }); + + it('should move to a different page if dropped on a page number', () => { + spyOn(document, 'elementFromPoint').and.returnValue({ + textContent: '2', + classList: { contains: (token: string) => true }, + } as Element); + + const event = { + previousIndex: 1, + currentIndex: 1, + dropPoint: { x: 100, y: 200 }, + } as CdkDragDrop; + + comp.drop(event); + expect(itemBitstreamsService.performBitstreamMoveRequest).toHaveBeenCalledWith(jasmine.any(Bundle), 1, 20, jasmine.any(Function)); + }); + }); + + describe('select', () => { + it('should select the bitstream', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(false); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).toHaveBeenCalledWith(jasmine.objectContaining({ bitstream: entry })); + }); + + it('should cancel the selection if the bitstream already is selected', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(false); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + itemBitstreamsService.getSelectedBitstream.and.returnValue({ bitstream: entry }); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).not.toHaveBeenCalled(); + expect(itemBitstreamsService.cancelSelection).toHaveBeenCalled(); + }); + + it('should not do anything if the user is holding down the select key', () => { + const event = new KeyboardEvent('keydown'); + spyOnProperty(event, 'repeat', 'get').and.returnValue(true); + + const entry = { } as BitstreamTableEntry; + comp.tableEntries$.next([entry]); + + itemBitstreamsService.getSelectedBitstream.and.returnValue({ bitstream: entry }); + + comp.select(event, entry); + expect(itemBitstreamsService.selectBitstreamEntry).not.toHaveBeenCalled(); + expect(itemBitstreamsService.cancelSelection).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts index 60654108c9..cf165383af 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -1,34 +1,92 @@ +import { + CdkDrag, + CdkDragDrop, + CdkDropList, +} from '@angular/cdk/drag-drop'; +import { + AsyncPipe, + CommonModule, +} from '@angular/common'; import { Component, - EventEmitter, Input, OnDestroy, OnInit, - Output, ViewChild, ViewContainerRef, } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { + NgbDropdownModule, + NgbTooltipModule, +} from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + Observable, + shareReplay, + Subscription, + switchMap, +} from 'rxjs'; +import { + filter, + map, + take, + tap, +} from 'rxjs/operators'; +import { PaginatedList } from 'src/app/core/data/paginated-list.model'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { BundleDataService } from '../../../../core/data/bundle-data.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; +import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; +import { FieldUpdates } from '../../../../core/data/object-updates/field-updates.model'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { Bundle } from '../../../../core/shared/bundle.model'; import { Item } from '../../../../core/shared/item.model'; +import { + getAllSucceededRemoteData, + paginatedListToArray, +} from '../../../../core/shared/operators'; +import { + hasNoValue, + hasValue, +} from '../../../../shared/empty.util'; +import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { PaginatedSearchOptions } from '../../../../shared/search/models/paginated-search-options.model'; +import { BrowserOnlyPipe } from '../../../../shared/utils/browser-only.pipe'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { getItemPageRoute } from '../../../item-page-routing-paths'; -import { ItemEditBitstreamDragHandleComponent } from '../item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; -import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; +import { + BitstreamTableEntry, + ItemBitstreamsService, + MOVE_KEY, + SelectedBitstreamTableEntry, + SelectionAction, +} from '../item-bitstreams.service'; @Component({ selector: 'ds-item-edit-bitstream-bundle', - styleUrls: ['../item-bitstreams.component.scss'], + styleUrls: ['../item-bitstreams.component.scss', './item-edit-bitstream-bundle.component.scss'], templateUrl: './item-edit-bitstream-bundle.component.html', imports: [ - PaginatedDragAndDropBitstreamListComponent, + CommonModule, TranslateModule, RouterLink, - ItemEditBitstreamDragHandleComponent, + AsyncPipe, + PaginationComponent, + NgbTooltipModule, + CdkDropList, + NgbDropdownModule, + CdkDrag, + BrowserOnlyPipe, ], standalone: true, }) @@ -38,12 +96,23 @@ import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element) */ export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy { + protected readonly FieldChangeType = FieldChangeType; /** * The view on the bundle information and bitstreams */ @ViewChild('bundleView', { static: true }) bundleView; + /** + * The view on the pagination component + */ + @ViewChild(PaginationComponent) paginationComponent: PaginationComponent; + + /** + * The view on the drag tooltip + */ + @ViewChild('dragTooltip') dragTooltip; + /** * The bundle to display bitstreams for */ @@ -60,11 +129,9 @@ export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy { @Input() columnSizes: ResponsiveTableSizes; /** - * Send an event when the user drops an object on the pagination - * The event contains details about the index the object came from and is dropped to (across the entirety of the list, - * not just within a single page) + * Whether this is the first in a series of bundle tables */ - @Output() dropObject: EventEmitter = new EventEmitter(); + @Input() isFirstTable = false; /** * The bootstrap sizes used for the Bundle Name column @@ -77,9 +144,65 @@ export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy { */ itemPageRoute: string; + /** + * The name of the bundle + */ + bundleName: string; + + /** + * The number of bitstreams in the bundle + */ + bundleSize: number; + + /** + * The bitstreams to show in the table + */ + bitstreamsRD$: Observable>>; + + /** + * The data to show in the table + */ + tableEntries$: BehaviorSubject = new BehaviorSubject([]); + + /** + * The initial page options to use for fetching the bitstreams + */ + paginationOptions: PaginationComponentOptions; + + /** + * The current page options + */ + currentPaginationOptions$: BehaviorSubject; + + /** + * The currently selected page size + */ + pageSize$: BehaviorSubject; + + /** + * The self url of the bundle, also used when retrieving fieldUpdates + */ + bundleUrl: string; + + /** + * The updates to the current bitstreams + */ + updates$: BehaviorSubject = new BehaviorSubject(null); + + /** + * Array containing all subscriptions created by this component + */ + subscriptions: Subscription[] = []; + + constructor( protected viewContainerRef: ViewContainerRef, public dsoNameService: DSONameService, + protected bundleService: BundleDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected paginationService: PaginationService, + protected requestService: RequestService, + protected itemBitstreamsService: ItemBitstreamsService, ) { } @@ -87,10 +210,384 @@ export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy { this.bundleNameColumn = this.columnSizes.combineColumns(0, 2); this.viewContainerRef.createEmbeddedView(this.bundleView); this.itemPageRoute = getItemPageRoute(this.item); + this.bundleName = this.dsoNameService.getName(this.bundle); + this.bundleUrl = this.bundle.self; + + this.initializePagination(); + this.initializeBitstreams(); + this.initializeSelectionActions(); } - ngOnDestroy(): void { + ngOnDestroy() { this.viewContainerRef.clear(); + this.subscriptions.forEach(sub => sub?.unsubscribe()); + } + + protected initializePagination() { + this.paginationOptions = this.itemBitstreamsService.getInitialBitstreamsPaginationOptions(this.bundleName); + + this.currentPaginationOptions$ = new BehaviorSubject(this.paginationOptions); + this.pageSize$ = new BehaviorSubject(this.paginationOptions.pageSize); + + this.subscriptions.push( + this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions) + .subscribe((pagination) => { + this.currentPaginationOptions$.next(pagination); + this.pageSize$.next(pagination.pageSize); + }), + ); + + } + + protected initializeBitstreams() { + this.bitstreamsRD$ = this.currentPaginationOptions$.pipe( + switchMap((page: PaginationComponentOptions) => { + const paginatedOptions = new PaginatedSearchOptions({ pagination: Object.assign({}, page) }); + return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( + switchMap((href) => this.requestService.hasByHref$(href)), + switchMap(() => this.bundleService.getBitstreams( + this.bundle.id, + paginatedOptions, + followLink('format'), + )), + ); + }), + getAllSucceededRemoteData(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.subscriptions.push( + this.bitstreamsRD$.pipe( + take(1), + tap(bitstreamsRD => this.bundleSize = bitstreamsRD.payload.totalElements), + paginatedListToArray(), + ).subscribe((bitstreams) => { + this.objectUpdatesService.initialize(this.bundleUrl, bitstreams, new Date()); + }), + + this.bitstreamsRD$.pipe( + paginatedListToArray(), + switchMap((bitstreams) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUrl, bitstreams)), + ).subscribe((updates) => this.updates$.next(updates)), + + this.bitstreamsRD$.pipe( + paginatedListToArray(), + map((bitstreams) => this.itemBitstreamsService.mapBitstreamsToTableEntries(bitstreams)), + ).subscribe((tableEntries) => this.tableEntries$.next(tableEntries)), + ); + } + + protected initializeSelectionActions() { + this.subscriptions.push( + this.itemBitstreamsService.getSelectionAction$().subscribe( + selectionAction => this.handleSelectionAction(selectionAction)), + ); + } + + /** + * Handles a change in selected bitstream by changing the pagination if the change happened on a different page + * @param selectionAction + */ + handleSelectionAction(selectionAction: SelectionAction) { + if (hasNoValue(selectionAction) || selectionAction.selectedEntry.bundle !== this.bundle) { + return; + } + + if (selectionAction.action === 'Moved') { + // If the currently selected bitstream belongs to this bundle, it has possibly moved to a different page. + // In that case we want to change the pagination to the new page. + this.redirectToCurrentPage(selectionAction.selectedEntry); + } + + if (selectionAction.action === 'Cancelled') { + // If the selection is cancelled (and returned to its original position), it is possible the previously selected + // bitstream is returned to a different page. In that case we want to change the pagination to the place where + // the bitstream was returned to. + this.redirectToOriginalPage(selectionAction.selectedEntry); + } + + if (selectionAction.action === 'Cleared') { + // If the selection is cleared, it is possible the previously selected bitstream is on a different page. In that + // case we want to change the pagination to the place where the bitstream is. + this.redirectToCurrentPage(selectionAction.selectedEntry); + } + } + + /** + * Redirect the user to the current page of the provided bitstream if it is on a different page. + * @param bitstreamEntry The entry that the current position will be taken from to determine the page to move to + * @protected + */ + protected redirectToCurrentPage(bitstreamEntry: SelectedBitstreamTableEntry) { + const currentPage = this.getCurrentPage(); + const selectedEntryPage = this.bundleIndexToPage(bitstreamEntry.currentPosition); + + if (currentPage !== selectedEntryPage) { + this.changeToPage(selectedEntryPage); + } + } + + /** + * Redirect the user to the original page of the provided bitstream if it is on a different page. + * @param bitstreamEntry The entry that the original position will be taken from to determine the page to move to + * @protected + */ + protected redirectToOriginalPage(bitstreamEntry: SelectedBitstreamTableEntry) { + const currentPage = this.getCurrentPage(); + const originPage = this.bundleIndexToPage(bitstreamEntry.originalPosition); + + if (currentPage !== originPage) { + this.changeToPage(originPage); + } + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(fieldUpdate: FieldUpdate): boolean { + return fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(fieldUpdate: FieldUpdate): boolean { + return fieldUpdate.changeType >= FieldChangeType.UPDATE; + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(bitstream: Bitstream): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, bitstream); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(bitstream: Bitstream): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, bitstream.uuid); + } + + /** + * Returns the css class for a table row depending on the state of the table entry. + * @param update + * @param bitstream + */ + getRowClass(update: FieldUpdate, bitstream: BitstreamTableEntry): string { + const selected = this.itemBitstreamsService.getSelectedBitstream(); + + if (hasValue(selected) && bitstream.id === selected.bitstream.id) { + return 'table-info'; + } + + switch (update.changeType) { + case FieldChangeType.UPDATE: + return 'table-warning'; + case FieldChangeType.ADD: + return 'table-success'; + case FieldChangeType.REMOVE: + return 'table-danger'; + default: + return 'bg-white'; + } + } + + /** + * Changes the page size to the provided page size. + * @param pageSize + */ + public doPageSizeChange(pageSize: number) { + this.paginationComponent.doPageSizeChange(pageSize); + } + + /** + * Handles start of dragging by opening the tooltip mentioning that it is possible to drag a bitstream to a different + * page by dropping it on the page number, only if there are multiple pages. + */ + dragStart() { + // Only open the drag tooltip when there are multiple pages + this.paginationComponent.shouldShowBottomPager.pipe( + take(1), + filter((hasMultiplePages) => hasMultiplePages), + ).subscribe(() => { + this.dragTooltip.open(); + }); + } + + /** + * Handles end of dragging by closing the tooltip. + */ + dragEnd() { + this.dragTooltip.close(); + } + + /** + * Handles dropping by calculation the target position, and changing the page if the bitstream was dropped on a + * different page. + * @param event + */ + drop(event: CdkDragDrop) { + const dragIndex = event.previousIndex; + let dropIndex = event.currentIndex; + const dragPage = this.getCurrentPage(); + let dropPage = this.getCurrentPage(); + + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object + const droppedOnElement = document.elementFromPoint(event.dropPoint.x, event.dropPoint.y); + if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent) && droppedOnElement.classList.contains('page-link')) { + // The user is hovering over a page, fetch the page's number from the element + let droppedPage = Number(droppedOnElement.textContent); + if (hasValue(droppedPage) && !Number.isNaN(droppedPage)) { + droppedPage -= 1; + + if (droppedPage !== dragPage) { + dropPage = droppedPage; + + if (dropPage > dragPage) { + // When moving to later page, place bitstream at the top + dropIndex = 0; + } else { + // When moving to earlier page, place bitstream at the bottom + dropIndex = this.getCurrentPageSize() - 1; + } + } + } + } + + const fromIndex = this.pageIndexToBundleIndex(dragIndex, dragPage); + const toIndex = this.pageIndexToBundleIndex(dropIndex, dropPage); + + if (fromIndex === toIndex) { + return; + } + + const selectedBitstream = this.tableEntries$.value[dragIndex]; + + const finish = () => { + this.itemBitstreamsService.announceMove(selectedBitstream.name, toIndex); + + if (dropPage !== this.getCurrentPage()) { + this.changeToPage(dropPage); + } + + this.itemBitstreamsService.displaySuccessNotification(MOVE_KEY); + }; + + this.itemBitstreamsService.performBitstreamMoveRequest(this.bundle, fromIndex, toIndex, finish); + } + + /** + * Handles a select action for the provided bitstream entry. + * If the selected bitstream is currently selected, the selection is cleared. + * If no, or a different bitstream, is selected, the provided bitstream becomes the selected bitstream. + * @param event The event that triggered the select action + * @param bitstream The bitstream that is the target of the select action + */ + select(event: UIEvent, bitstream: BitstreamTableEntry) { + event.preventDefault(); + + if (event instanceof KeyboardEvent && event.repeat) { + // Don't handle hold events, otherwise it would change rapidly between being selected and unselected + return; + } + + const selectedBitstream = this.itemBitstreamsService.getSelectedBitstream(); + + if (hasValue(selectedBitstream) && selectedBitstream.bitstream === bitstream) { + this.itemBitstreamsService.cancelSelection(); + } else { + const selectionObject = this.createBitstreamSelectionObject(bitstream); + + if (hasNoValue(selectionObject)) { + console.warn('Failed to create selection object'); + return; + } + + this.itemBitstreamsService.selectBitstreamEntry(selectionObject); + } + } + + /** + * Creates a {@link SelectedBitstreamTableEntry} from the provided {@link BitstreamTableEntry} so it can be given + * to the {@link ItemBitstreamsService} to select the table entry. + * @param bitstream The table entry to create the selection object from. + * @protected + */ + protected createBitstreamSelectionObject(bitstream: BitstreamTableEntry): SelectedBitstreamTableEntry { + const pageIndex = this.findBitstreamPageIndex(bitstream); + + if (pageIndex === -1) { + return null; + } + + const position = this.pageIndexToBundleIndex(pageIndex, this.getCurrentPage()); + + return { + bitstream: bitstream, + bundle: this.bundle, + bundleSize: this.bundleSize, + currentPosition: position, + originalPosition: position, + }; + } + + /** + * Returns the index of the provided {@link BitstreamTableEntry} relative to the current page + * If the current page size is 10, it will return a value from 0 to 9 (inclusive) + * Returns -1 if the provided bitstream could not be found + * @protected + */ + protected findBitstreamPageIndex(bitstream: BitstreamTableEntry): number { + const entries = this.tableEntries$.value; + return entries.findIndex(entry => entry === bitstream); + } + + /** + * Returns the current zero-indexed page + * @protected + */ + protected getCurrentPage(): number { + // The pagination component uses one-based numbering while zero-based numbering is more convenient for calculations + return this.currentPaginationOptions$.value.currentPage - 1; + } + + /** + * Returns the current page size + * @protected + */ + protected getCurrentPageSize(): number { + return this.currentPaginationOptions$.value.pageSize; + } + + /** + * Converts an index relative to the page to an index relative to the bundle + * @param index The index relative to the page + * @param page The zero-indexed page number + * @protected + */ + protected pageIndexToBundleIndex(index: number, page: number) { + return page * this.getCurrentPageSize() + index; + } + + /** + * Calculates the zero-indexed page number from the index relative to the bundle + * @param index The index relative to the bundle + * @protected + */ + protected bundleIndexToPage(index: number) { + return Math.floor(index / this.getCurrentPageSize()); + } + + /** + * Change the pagination for this bundle to the provided zero-indexed page + * @param page The zero-indexed page to change to + * @protected + */ + protected changeToPage(page: number) { + // Increments page by one because zero-indexing is way easier for calculations but the pagination component + // uses one-indexing. + this.paginationComponent.doPageChange(page + 1); } } diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html deleted file mode 100644 index c3d6ebc823..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - -
- -
- -
- -
-
-
-
-
-
- -
diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts deleted file mode 100644 index cbb3314252..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; -import { take } from 'rxjs/operators'; - -import { BundleDataService } from '../../../../../core/data/bundle-data.service'; -import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service'; -import { RequestService } from '../../../../../core/data/request.service'; -import { PaginationService } from '../../../../../core/pagination/pagination.service'; -import { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model'; -import { Bundle } from '../../../../../core/shared/bundle.model'; -import { PaginationComponent } from '../../../../../shared/pagination/pagination.component'; -import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; -import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes'; -import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { ActivatedRouteStub } from '../../../../../shared/testing/active-router.stub'; -import { PaginationServiceStub } from '../../../../../shared/testing/pagination-service.stub'; -import { createPaginatedList } from '../../../../../shared/testing/utils.test'; -import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; -import { VarDirective } from '../../../../../shared/utils/var.directive'; -import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list.component'; - -describe('PaginatedDragAndDropBitstreamListComponent', () => { - let comp: PaginatedDragAndDropBitstreamListComponent; - let fixture: ComponentFixture; - let objectUpdatesService: ObjectUpdatesService; - let bundleService: BundleDataService; - let objectValuesPipe: ObjectValuesPipe; - let requestService: RequestService; - let paginationService; - - const columnSizes = new ResponsiveTableSizes([ - new ResponsiveColumnSizes(2, 2, 3, 4, 4), - new ResponsiveColumnSizes(2, 3, 3, 3, 3), - new ResponsiveColumnSizes(2, 2, 2, 2, 2), - new ResponsiveColumnSizes(6, 5, 4, 3, 3), - ]); - - const bundle = Object.assign(new Bundle(), { - id: 'bundle-1', - uuid: 'bundle-1', - _links: { - self: { href: 'bundle-1-selflink' }, - }, - }); - const date = new Date(); - const format = Object.assign(new BitstreamFormat(), { - shortDescription: 'PDF', - }); - const bitstream1 = Object.assign(new Bitstream(), { - uuid: 'bitstreamUUID1', - name: 'Fake Bitstream 1', - bundleName: 'ORIGINAL', - description: 'Description', - format: createSuccessfulRemoteDataObject$(format), - }); - const fieldUpdate1 = { - field: bitstream1, - changeType: undefined, - }; - const bitstream2 = Object.assign(new Bitstream(), { - uuid: 'bitstreamUUID2', - name: 'Fake Bitstream 2', - bundleName: 'ORIGINAL', - description: 'Description', - format: createSuccessfulRemoteDataObject$(format), - }); - const fieldUpdate2 = { - field: bitstream2, - changeType: undefined, - }; - - beforeEach(waitForAsync(() => { - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - getFieldUpdates: observableOf({ - [bitstream1.uuid]: fieldUpdate1, - [bitstream2.uuid]: fieldUpdate2, - }), - getFieldUpdatesExclusive: observableOf({ - [bitstream1.uuid]: fieldUpdate1, - [bitstream2.uuid]: fieldUpdate2, - }), - getFieldUpdatesByCustomOrder: observableOf({ - [bitstream1.uuid]: fieldUpdate1, - [bitstream2.uuid]: fieldUpdate2, - }), - saveMoveFieldUpdate: {}, - saveRemoveFieldUpdate: {}, - removeSingleFieldUpdate: {}, - saveAddFieldUpdate: {}, - discardFieldUpdates: {}, - reinstateFieldUpdates: observableOf(true), - initialize: {}, - getUpdatedFields: observableOf([bitstream1, bitstream2]), - getLastModified: observableOf(date), - hasUpdates: observableOf(true), - isReinstatable: observableOf(false), - isValidPage: observableOf(true), - initializeWithCustomOrder: {}, - addPageToCustomOrder: {}, - }, - ); - - bundleService = jasmine.createSpyObj('bundleService', { - getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])), - getBitstreamsEndpoint: observableOf(''), - }); - - objectValuesPipe = new ObjectValuesPipe(); - - requestService = jasmine.createSpyObj('requestService', { - hasByHref$: observableOf(true), - }); - - paginationService = new PaginationServiceStub(); - - TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), PaginatedDragAndDropBitstreamListComponent, VarDirective], - providers: [ - { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: BundleDataService, useValue: bundleService }, - { provide: ObjectValuesPipe, useValue: objectValuesPipe }, - { provide: RequestService, useValue: requestService }, - { provide: PaginationService, useValue: paginationService }, - { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, - ], schemas: [ - NO_ERRORS_SCHEMA, - ], - }) - .overrideComponent(PaginatedDragAndDropBitstreamListComponent, { - remove: { - imports: [PaginationComponent], - }, - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(PaginatedDragAndDropBitstreamListComponent); - comp = fixture.componentInstance; - comp.bundle = bundle; - comp.columnSizes = columnSizes; - fixture.detectChanges(); - }); - - it('should initialize the objectsRD$', (done) => { - comp.objectsRD$.pipe(take(1)).subscribe((objects) => { - expect(objects.payload.page).toEqual([bitstream1, bitstream2]); - done(); - }); - }); - - it('should initialize the URL', () => { - expect(comp.url).toEqual(bundle.self); - }); -}); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts deleted file mode 100644 index 24d827ac61..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - CdkDrag, - CdkDragHandle, - CdkDropList, -} from '@angular/cdk/drag-drop'; -import { - AsyncPipe, - NgClass, - NgForOf, - NgIf, -} from '@angular/common'; -import { - Component, - ElementRef, - Input, - OnInit, -} from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; -import { switchMap } from 'rxjs/operators'; - -import { BundleDataService } from '../../../../../core/data/bundle-data.service'; -import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service'; -import { RequestService } from '../../../../../core/data/request.service'; -import { PaginationService } from '../../../../../core/pagination/pagination.service'; -import { Bitstream } from '../../../../../core/shared/bitstream.model'; -import { Bundle } from '../../../../../core/shared/bundle.model'; -import { ThemedLoadingComponent } from '../../../../../shared/loading/themed-loading.component'; -import { PaginationComponent } from '../../../../../shared/pagination/pagination.component'; -import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model'; -import { AbstractPaginatedDragAndDropListComponent } from '../../../../../shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component'; -import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { PaginatedSearchOptions } from '../../../../../shared/search/models/paginated-search-options.model'; -import { followLink } from '../../../../../shared/utils/follow-link-config.model'; -import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; -import { VarDirective } from '../../../../../shared/utils/var.directive'; -import { ItemEditBitstreamComponent } from '../../item-edit-bitstream/item-edit-bitstream.component'; -import { ItemEditBitstreamDragHandleComponent } from '../../item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; - -@Component({ - selector: 'ds-paginated-drag-and-drop-bitstream-list', - styleUrls: ['../../item-bitstreams.component.scss'], - templateUrl: './paginated-drag-and-drop-bitstream-list.component.html', - imports: [ - AsyncPipe, - NgIf, - PaginationComponent, - NgClass, - VarDirective, - CdkDropList, - NgForOf, - CdkDrag, - ItemEditBitstreamComponent, - ItemEditBitstreamDragHandleComponent, - CdkDragHandle, - ThemedLoadingComponent, - TranslateModule, - ], - standalone: true, -}) -/** - * A component listing edit-bitstream rows for each bitstream within the given bundle. - * This component makes use of the AbstractPaginatedDragAndDropListComponent, allowing for users to drag and drop - * bitstreams within the paginated list. To drag and drop a bitstream between two pages, drag the row on top of the - * page number you want the bitstream to end up at. Doing so will add the bitstream to the top of that page. - */ -export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginatedDragAndDropListComponent implements OnInit { - /** - * The bundle to display bitstreams for - */ - @Input() bundle: Bundle; - - /** - * The bootstrap sizes used for the columns within this table - */ - @Input() columnSizes: ResponsiveTableSizes; - - constructor(protected objectUpdatesService: ObjectUpdatesService, - protected elRef: ElementRef, - protected objectValuesPipe: ObjectValuesPipe, - protected bundleService: BundleDataService, - protected paginationService: PaginationService, - protected requestService: RequestService) { - super(objectUpdatesService, elRef, objectValuesPipe, paginationService); - } - - ngOnInit() { - super.ngOnInit(); - } - - /** - * Initialize the bitstreams observable depending on currentPage$ - */ - initializeObjectsRD(): void { - this.objectsRD$ = this.currentPage$.pipe( - switchMap((page: PaginationComponentOptions) => { - const paginatedOptions = new PaginatedSearchOptions({ pagination: Object.assign({}, page) }); - return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( - switchMap((href) => this.requestService.hasByHref$(href)), - switchMap(() => this.bundleService.getBitstreams( - this.bundle.id, - paginatedOptions, - followLink('format'), - )), - ); - }), - ); - } - - /** - * Initialize the URL used for the field-update store, in this case the bundle's self-link - */ - initializeURL(): void { - this.url = this.bundle.self; - } -} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html deleted file mode 100644 index 1bce8667ee..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html +++ /dev/null @@ -1,5 +0,0 @@ - -
- -
-
diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts deleted file mode 100644 index b993eb7193..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - Component, - OnDestroy, - OnInit, - ViewChild, - ViewContainerRef, -} from '@angular/core'; -import { TranslateModule } from '@ngx-translate/core'; - -@Component({ - selector: 'ds-item-edit-bitstream-drag-handle', - styleUrls: ['../item-bitstreams.component.scss'], - templateUrl: './item-edit-bitstream-drag-handle.component.html', - imports: [ - TranslateModule, - ], - standalone: true, -}) -/** - * Component displaying a drag handle for the item-edit-bitstream page - * Creates an embedded view of the contents - * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element) - */ -export class ItemEditBitstreamDragHandleComponent implements OnInit, OnDestroy { - /** - * The view on the drag-handle - */ - @ViewChild('handleView', { static: true }) handleView; - - constructor(private viewContainerRef: ViewContainerRef) { - } - - ngOnInit(): void { - this.viewContainerRef.createEmbeddedView(this.handleView); - } - - ngOnDestroy(): void { - this.viewContainerRef.clear(); - } - -} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html deleted file mode 100644 index 7d52ab220f..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ /dev/null @@ -1,55 +0,0 @@ - -
- -
- - {{ bitstreamName }} - -
-
-
-
-
- {{ bitstream?.firstMetadataValue('dc.description') }} -
-
-
-
-
- - {{ (format$ | async)?.shortDescription }} - -
-
-
-
-
- - - - - - -
-
-
-
diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts deleted file mode 100644 index bceb0a1207..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { ActivatedRoute } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; - -import { getBitstreamDownloadRoute } from '../../../../app-routing-paths'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; -import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; -import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; -import { VarDirective } from '../../../../shared/utils/var.directive'; -import { ItemEditBitstreamComponent } from './item-edit-bitstream.component'; - -let comp: ItemEditBitstreamComponent; -let fixture: ComponentFixture; - -const columnSizes = new ResponsiveTableSizes([ - new ResponsiveColumnSizes(2, 2, 3, 4, 4), - new ResponsiveColumnSizes(2, 3, 3, 3, 3), - new ResponsiveColumnSizes(2, 2, 2, 2, 2), - new ResponsiveColumnSizes(6, 5, 4, 3, 3), -]); - -const format = Object.assign(new BitstreamFormat(), { - shortDescription: 'PDF', -}); -const bitstream = Object.assign(new Bitstream(), { - uuid: 'bitstreamUUID', - name: 'Fake Bitstream', - bundleName: 'ORIGINAL', - description: 'Description', - _links: { - content: { href: 'content-link' }, - }, - - format: createSuccessfulRemoteDataObject$(format), -}); -const fieldUpdate = { - field: bitstream, - changeType: undefined, -}; -const date = new Date(); -const url = 'thisUrl'; - -let objectUpdatesService: ObjectUpdatesService; - -describe('ItemEditBitstreamComponent', () => { - beforeEach(waitForAsync(() => { - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - getFieldUpdates: observableOf({ - [bitstream.uuid]: fieldUpdate, - }), - getFieldUpdatesExclusive: observableOf({ - [bitstream.uuid]: fieldUpdate, - }), - saveRemoveFieldUpdate: {}, - removeSingleFieldUpdate: {}, - saveAddFieldUpdate: {}, - discardFieldUpdates: {}, - reinstateFieldUpdates: observableOf(true), - initialize: {}, - getUpdatedFields: observableOf([bitstream]), - getLastModified: observableOf(date), - hasUpdates: observableOf(true), - isReinstatable: observableOf(false), - isValidPage: observableOf(true), - }, - ); - - TestBed.configureTestingModule({ - imports: [ - TranslateModule.forRoot(), - RouterTestingModule.withRoutes([]), - ItemEditBitstreamComponent, - VarDirective, - ], - providers: [ - { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, - ], schemas: [ - NO_ERRORS_SCHEMA, - ], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ItemEditBitstreamComponent); - comp = fixture.componentInstance; - comp.fieldUpdate = fieldUpdate; - comp.bundleUrl = url; - comp.columnSizes = columnSizes; - comp.ngOnChanges(undefined); - fixture.detectChanges(); - }); - - describe('when remove is called', () => { - beforeEach(() => { - comp.remove(); - }); - - it('should call saveRemoveFieldUpdate on objectUpdatesService', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, bitstream); - }); - }); - - describe('when undo is called', () => { - beforeEach(() => { - comp.undo(); - }); - - it('should call removeSingleFieldUpdate on objectUpdatesService', () => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, bitstream.uuid); - }); - }); - - describe('when canRemove is called', () => { - it('should return true', () => { - expect(comp.canRemove()).toEqual(true); - }); - }); - - describe('when canUndo is called', () => { - it('should return false', () => { - expect(comp.canUndo()).toEqual(false); - }); - }); - - describe('when the component loads', () => { - it('should contain download button with a valid link to the bitstreams download page', () => { - fixture.detectChanges(); - const downloadBtnHref = fixture.debugElement.query(By.css('[data-test="download-button"]')).nativeElement.getAttribute('href'); - expect(downloadBtnHref).toEqual(comp.bitstreamDownloadUrl); - }); - }); - - describe('when the bitstreamDownloadUrl property gets populated', () => { - it('should contain the bitstream download page route', () => { - expect(comp.bitstreamDownloadUrl).not.toEqual(bitstream._links.content.href); - expect(comp.bitstreamDownloadUrl).toEqual(getBitstreamDownloadRoute(bitstream)); - }); - }); -}); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts deleted file mode 100644 index 81950d3321..0000000000 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - AsyncPipe, - NgIf, -} from '@angular/common'; -import { - Component, - Input, - OnChanges, - OnDestroy, - OnInit, - SimpleChanges, - ViewChild, - ViewContainerRef, -} from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateModule } from '@ngx-translate/core'; -import cloneDeep from 'lodash/cloneDeep'; -import { Observable } from 'rxjs'; - -import { getBitstreamDownloadRoute } from '../../../../app-routing-paths'; -import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; -import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model'; -import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { Bitstream } from '../../../../core/shared/bitstream.model'; -import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; -import { - getFirstSucceededRemoteData, - getRemoteDataPayload, -} from '../../../../core/shared/operators'; -import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; -import { BrowserOnlyPipe } from '../../../../shared/utils/browser-only.pipe'; - -@Component({ - selector: 'ds-item-edit-bitstream', - styleUrls: ['../item-bitstreams.component.scss'], - templateUrl: './item-edit-bitstream.component.html', - imports: [ - RouterLink, - TranslateModule, - BrowserOnlyPipe, - NgbTooltipModule, - AsyncPipe, - NgIf, - ], - standalone: true, -}) -/** - * Component that displays a single bitstream of an item on the edit page - * Creates an embedded view of the contents - * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element) - */ -export class ItemEditBitstreamComponent implements OnChanges, OnDestroy, OnInit { - - /** - * The view on the bitstream - */ - @ViewChild('bitstreamView', { static: true }) bitstreamView; - - /** - * The current field, value and state of the bitstream - */ - @Input() fieldUpdate: FieldUpdate; - - /** - * The url of the bundle - */ - @Input() bundleUrl: string; - - /** - * The bootstrap sizes used for the columns within this table - */ - @Input() columnSizes: ResponsiveTableSizes; - - /** - * The bitstream of this field - */ - bitstream: Bitstream; - - /** - * The bitstream's name - */ - bitstreamName: string; - - /** - * The bitstream's download url - */ - bitstreamDownloadUrl: string; - - /** - * The format of the bitstream - */ - format$: Observable; - - constructor(private objectUpdatesService: ObjectUpdatesService, - private dsoNameService: DSONameService, - private viewContainerRef: ViewContainerRef) { - } - - ngOnInit(): void { - this.viewContainerRef.createEmbeddedView(this.bitstreamView); - } - - ngOnDestroy(): void { - this.viewContainerRef.clear(); - } - - /** - * Update the current bitstream and its format on changes - * @param changes - */ - ngOnChanges(changes: SimpleChanges): void { - this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream; - this.bitstreamName = this.dsoNameService.getName(this.bitstream); - this.bitstreamDownloadUrl = getBitstreamDownloadRoute(this.bitstream); - this.format$ = this.bitstream.format.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - } - - /** - * Sends a new remove update for this field to the object updates service - */ - remove(): void { - this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, this.bitstream); - } - - /** - * Cancels the current update for this field in the object updates service - */ - undo(): void { - this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, this.bitstream.uuid); - } - - /** - * Check if a user should be allowed to remove this field - */ - canRemove(): boolean { - return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; - } - - /** - * Check if a user should be allowed to cancel the update to this field - */ - canUndo(): boolean { - return this.fieldUpdate.changeType?.valueOf() >= 0; - } - -} diff --git a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts index 1655856a3e..ae791ccd8e 100644 --- a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts @@ -320,8 +320,8 @@ export class ItemDeleteComponent this.linkService.resolveLinks( relationship, followLink('relationshipType'), - followLink('leftItem'), - followLink('rightItem'), + followLink('leftItem', undefined, followLink('accessStatus')), + followLink('rightItem', undefined, followLink('accessStatus')), ); return relationship.relationshipType.pipe( getFirstSucceededRemoteData(), diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 48415000be..4784e8fc5c 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -110,7 +110,7 @@ describe('EditRelationshipListComponent', () => { }, }; - function init(leftType: string, rightType: string): void { + function init(leftType: string, rightType: string, leftMaxCardinality?: number, rightMaxCardinality?: number): void { entityTypeLeft = Object.assign(new ItemType(), { id: leftType, uuid: leftType, @@ -130,6 +130,8 @@ describe('EditRelationshipListComponent', () => { rightType: createSuccessfulRemoteDataObject$(entityTypeRight), leftwardType: `is${rightType}Of${leftType}`, rightwardType: `is${leftType}Of${rightType}`, + leftMaxCardinality: leftMaxCardinality, + rightMaxCardinality: rightMaxCardinality, }); paginationOptions = Object.assign(new PaginationComponentOptions(), { @@ -402,4 +404,31 @@ describe('EditRelationshipListComponent', () => { })); }); }); + + describe('Is repeatable relationship', () => { + beforeEach(waitForAsync(() => { + currentItemIsLeftItem$ = new BehaviorSubject(true); + })); + describe('when max cardinality is 1', () => { + beforeEach(waitForAsync(() => init('Publication', 'OrgUnit', 1, undefined))); + it('should return false', () => { + const result = (comp as any).isRepeatable(); + expect(result).toBeFalse(); + }); + }); + describe('when max cardinality is 2', () => { + beforeEach(waitForAsync(() => init('Publication', 'OrgUnit', 2, undefined))); + it('should return true', () => { + const result = (comp as any).isRepeatable(); + expect(result).toBeTrue(); + }); + }); + describe('when max cardinality is undefined', () => { + beforeEach(waitForAsync(() => init('Publication', 'OrgUnit', undefined, undefined))); + it('should return true', () => { + const result = (comp as any).isRepeatable(); + expect(result).toBeTrue(); + }); + }); + }); }); diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 656d608935..7f1d9fc5db 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -264,6 +264,22 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { return update && update.field ? update.field.uuid : undefined; } + /** + * Check whether the current entity can have multiple relationships of this type + * This is based on the max cardinality of the relationship + * @private + */ + private isRepeatable(): boolean { + const isLeft = this.currentItemIsLeftItem$.getValue(); + if (isLeft) { + const leftMaxCardinality = this.relationshipType.leftMaxCardinality; + return hasNoValue(leftMaxCardinality) || leftMaxCardinality > 1; + } else { + const rightMaxCardinality = this.relationshipType.rightMaxCardinality; + return hasNoValue(rightMaxCardinality) || rightMaxCardinality > 1; + } + } + /** * Open the dynamic lookup modal to search for items to add as relationships */ @@ -281,6 +297,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { modalComp.toAdd = []; modalComp.toRemove = []; modalComp.isPending = false; + modalComp.repeatable = this.isRepeatable(); modalComp.hiddenQuery = '-search.resourceid:' + this.item.uuid; this.item.owningCollection.pipe( @@ -478,7 +495,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy { ); // this adds thumbnail images when required by configuration - const linksToFollow: FollowLinkConfig[] = itemLinksToFollow(this.fetchThumbnail); + const linksToFollow: FollowLinkConfig[] = itemLinksToFollow(this.fetchThumbnail, this.appConfig.item.showAccessStatuses); this.subs.push( observableCombineLatest([ diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html index 44a3657fa5..4fc0b41136 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html @@ -18,7 +18,10 @@ - diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts index 8e93175c46..5670128733 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -83,4 +83,20 @@ describe('MetadataValuesComponent', () => { expect(comp.hasLink(mdValue)).toBe(true); }); + it('should return correct target and rel for internal links', () => { + spyOn(comp, 'hasInternalLink').and.returnValue(true); + const urlValue = '/internal-link'; + const result = comp.getLinkAttributes(urlValue); + expect(result.target).toBe('_self'); + expect(result.rel).toBe(''); + }); + + it('should return correct target and rel for external links', () => { + spyOn(comp, 'hasInternalLink').and.returnValue(false); + const urlValue = 'https://www.dspace.org'; + const result = comp.getLinkAttributes(urlValue); + expect(result.target).toBe('_blank'); + expect(result.rel).toBe('noopener noreferrer'); + }); + }); diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 1a73d692eb..354879de2b 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -134,4 +134,16 @@ export class MetadataValuesComponent implements OnChanges { hasInternalLink(linkValue: string): boolean { return linkValue.startsWith(environment.ui.baseUrl); } + + /** + * This method performs a validation and determines the target of the url. + * @returns - Returns the target url. + */ + getLinkAttributes(urlValue: string): { target: string, rel: string } { + if (this.hasInternalLink(urlValue)) { + return { target: '_self', rel: '' }; + } else { + return { target: '_blank', rel: 'noopener noreferrer' }; + } + } } diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 684ea56459..854d66fabe 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -27,7 +27,6 @@ export const ROUTES: Route[] = [ resolve: { dso: itemPageResolver, breadcrumb: itemBreadcrumbResolver, - menu: dsoEditMenuResolver, }, runGuardsAndResolvers: 'always', children: [ @@ -35,10 +34,16 @@ export const ROUTES: Route[] = [ path: '', component: ThemedItemPageComponent, pathMatch: 'full', + resolve: { + menu: dsoEditMenuResolver, + }, }, { path: 'full', component: ThemedFullItemPageComponent, + resolve: { + menu: dsoEditMenuResolver, + }, }, { path: ITEM_EDIT_PATH, diff --git a/src/app/item-page/item-page.resolver.ts b/src/app/item-page/item-page.resolver.ts index 431d8522e7..ef59bf00b8 100644 --- a/src/app/item-page/item-page.resolver.ts +++ b/src/app/item-page/item-page.resolver.ts @@ -18,7 +18,7 @@ import { redirectOn4xx } from '../core/shared/authorized.operators'; import { Item } from '../core/shared/item.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { hasValue } from '../shared/empty.util'; -import { ITEM_PAGE_LINKS_TO_FOLLOW } from './item.resolver'; +import { getItemPageLinksToFollow } from './item.resolver'; import { getItemPageRoute } from './item-page-routing-paths'; /** @@ -40,16 +40,22 @@ export const itemPageResolver: ResolveFn> = ( store: Store = inject(Store), authService: AuthService = inject(AuthService), ): Observable> => { - return itemService.findById( + const itemRD$ = itemService.findById( route.params.id, true, false, - ...ITEM_PAGE_LINKS_TO_FOLLOW, + ...getItemPageLinksToFollow(), ).pipe( getFirstCompletedRemoteData(), redirectOn4xx(router, authService), + ); + + itemRD$.subscribe((itemRD: RemoteData) => { + store.dispatch(new ResolvedAction(state.url, itemRD.payload)); + }); + + return itemRD$.pipe( map((rd: RemoteData) => { - store.dispatch(new ResolvedAction(state.url, rd.payload)); if (rd.hasSucceeded && hasValue(rd.payload)) { const thisRoute = state.url; diff --git a/src/app/item-page/item.resolver.ts b/src/app/item-page/item.resolver.ts index 343e0d1983..1fb00d9165 100644 --- a/src/app/item-page/item.resolver.ts +++ b/src/app/item-page/item.resolver.ts @@ -7,6 +7,7 @@ import { import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; import { AppState } from '../app.reducer'; import { ItemDataService } from '../core/data/item-data.service'; import { RemoteData } from '../core/data/remote-data'; @@ -22,15 +23,21 @@ import { * The self links defined in this list are expected to be requested somewhere in the near future * Requesting them as embeds will limit the number of requests */ -export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('owningCollection', {}, - followLink('parentCommunity', {}, - followLink('parentCommunity')), - ), - followLink('relationships'), - followLink('version', {}, followLink('versionhistory')), - followLink('thumbnail'), -]; +export function getItemPageLinksToFollow(): FollowLinkConfig[] { + const followLinks: FollowLinkConfig[] = [ + followLink('owningCollection', {}, + followLink('parentCommunity', {}, + followLink('parentCommunity')), + ), + followLink('relationships'), + followLink('version', {}, followLink('versionhistory')), + followLink('thumbnail'), + ]; + if (environment.item.showAccessStatuses) { + followLinks.push(followLink('accessStatus')); + } + return followLinks; +} export const itemResolver: ResolveFn> = ( route: ActivatedRouteSnapshot, @@ -42,7 +49,7 @@ export const itemResolver: ResolveFn> = ( route.params.id, true, false, - ...ITEM_PAGE_LINKS_TO_FOLLOW, + ...getItemPageLinksToFollow(), ).pipe( getFirstCompletedRemoteData(), ); diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts index 5825ecc4e4..6a3b8af937 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts @@ -19,6 +19,7 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject, + catchError, Observable, } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -34,6 +35,7 @@ import { Item } from '../../../core/shared/item.model'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { AlertComponent } from '../../../shared/alert/alert.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObjectFromError$ } from '../../../shared/remote-data.utils'; @Component({ selector: 'ds-orcid-auth', @@ -203,13 +205,14 @@ export class OrcidAuthComponent implements OnInit, OnChanges { this.unlinkProcessing.next(true); this.orcidAuthService.unlinkOrcidByItem(this.item).pipe( getFirstCompletedRemoteData(), + catchError(createFailedRemoteDataObjectFromError$), ).subscribe((remoteData: RemoteData) => { this.unlinkProcessing.next(false); - if (remoteData.isSuccess) { + if (remoteData.hasFailed) { + this.notificationsService.error(this.translateService.get('person.page.orcid.unlink.error')); + } else { this.notificationsService.success(this.translateService.get('person.page.orcid.unlink.success')); this.unlink.emit(); - } else { - this.notificationsService.error(this.translateService.get('person.page.orcid.unlink.error')); } }); } diff --git a/src/app/item-page/orcid-page/orcid-page.component.ts b/src/app/item-page/orcid-page/orcid-page.component.ts index a3c31e791d..7e634fdeca 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.ts +++ b/src/app/item-page/orcid-page/orcid-page.component.ts @@ -20,6 +20,7 @@ import { combineLatest, } from 'rxjs'; import { + filter, map, take, } from 'rxjs/operators'; @@ -187,8 +188,20 @@ export class OrcidPageComponent implements OnInit { */ private clearRouteParams(): void { // update route removing the code from query params - const redirectUrl = this.router.url.split('?')[0]; - this.router.navigate([redirectUrl]); + this.route.queryParamMap + .pipe( + filter((paramMap: ParamMap) => isNotEmpty(paramMap.keys)), + map(_ => Object.assign({})), + take(1), + ).subscribe(queryParams => + this.router.navigate( + [], + { + relativeTo: this.route, + queryParams, + }, + ), + ); } } diff --git a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts index a64feb0ae1..bef1378209 100644 --- a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts +++ b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts @@ -180,6 +180,7 @@ describe('OrcidSyncSettingsComponent test suite', () => { scheduler = getTestScheduler(); fixture = TestBed.createComponent(OrcidSyncSettingsComponent); comp = fixture.componentInstance; + researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); comp.item = mockItemLinkedToOrcid; fixture.detectChanges(); })); @@ -216,7 +217,6 @@ describe('OrcidSyncSettingsComponent test suite', () => { }); it('should call updateByOrcidOperations properly', () => { - researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); researcherProfileService.patch.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); const expectedOps: Operation[] = [ { @@ -245,7 +245,6 @@ describe('OrcidSyncSettingsComponent test suite', () => { }); it('should show notification on success', () => { - researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); researcherProfileService.patch.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); scheduler.schedule(() => comp.onSubmit(formGroup)); @@ -257,6 +256,8 @@ describe('OrcidSyncSettingsComponent test suite', () => { it('should show notification on error', () => { researcherProfileService.findByRelatedItem.and.returnValue(createFailedRemoteDataObject$()); + comp.item = mockItemLinkedToOrcid; + fixture.detectChanges(); scheduler.schedule(() => comp.onSubmit(formGroup)); scheduler.flush(); @@ -266,7 +267,6 @@ describe('OrcidSyncSettingsComponent test suite', () => { }); it('should show notification on error', () => { - researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); researcherProfileService.patch.and.returnValue(createFailedRemoteDataObject$()); scheduler.schedule(() => comp.onSubmit(formGroup)); diff --git a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts index 424baf45fb..7c3f71785c 100644 --- a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts +++ b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts @@ -3,6 +3,7 @@ import { Component, EventEmitter, Input, + OnDestroy, OnInit, Output, } from '@angular/core'; @@ -15,17 +16,32 @@ import { TranslateService, } from '@ngx-translate/core'; import { Operation } from 'fast-json-patch'; -import { of } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { + BehaviorSubject, + Observable, +} from 'rxjs'; +import { + catchError, + filter, + map, + switchMap, + take, + takeUntil, +} from 'rxjs/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; import { ResearcherProfileDataService } from '../../../core/profile/researcher-profile-data.service'; import { Item } from '../../../core/shared/item.model'; -import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '../../../core/shared/operators'; import { AlertComponent } from '../../../shared/alert/alert.component'; import { AlertType } from '../../../shared/alert/alert-type'; +import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObjectFromError$ } from '../../../shared/remote-data.utils'; @Component({ selector: 'ds-orcid-sync-setting', @@ -39,14 +55,9 @@ import { NotificationsService } from '../../../shared/notifications/notification ], standalone: true, }) -export class OrcidSyncSettingsComponent implements OnInit { +export class OrcidSyncSettingsComponent implements OnInit, OnDestroy { protected readonly AlertType = AlertType; - /** - * The item for which showing the orcid settings - */ - @Input() item: Item; - /** * The prefix used for i18n keys */ @@ -91,12 +102,39 @@ export class OrcidSyncSettingsComponent implements OnInit { * An event emitted when settings are updated */ @Output() settingsUpdated: EventEmitter = new EventEmitter(); + /** + * Emitter that triggers onDestroy lifecycle + * @private + */ + readonly #destroy$ = new EventEmitter(); + /** + * {@link BehaviorSubject} that reflects {@link item} input changes + * @private + */ + readonly #item$ = new BehaviorSubject(null); + /** + * {@link Observable} that contains {@link ResearcherProfile} linked to the {@link #item$} + * @private + */ + #researcherProfile$: Observable; constructor(private researcherProfileService: ResearcherProfileDataService, private notificationsService: NotificationsService, private translateService: TranslateService) { } + /** + * The item for which showing the orcid settings + */ + @Input() + set item(item: Item) { + this.#item$.next(item); + } + + ngOnDestroy(): void { + this.#destroy$.next(); + } + /** * Init orcid settings form */ @@ -128,20 +166,21 @@ export class OrcidSyncSettingsComponent implements OnInit { }; }); - const syncProfilePreferences = this.item.allMetadataValues('dspace.orcid.sync-profile'); + this.updateSyncProfileOptions(this.#item$.asObservable()); + this.updateSyncPreferences(this.#item$.asObservable()); - this.syncProfileOptions = ['BIOGRAPHICAL', 'IDENTIFIERS'] - .map((value) => { - return { - label: this.messagePrefix + '.sync-profile.' + value.toLowerCase(), - value: value, - checked: syncProfilePreferences.includes(value), - }; - }); - - this.currentSyncMode = this.getCurrentPreference('dspace.orcid.sync-mode', ['BATCH', 'MANUAL'], 'MANUAL'); - this.currentSyncPublications = this.getCurrentPreference('dspace.orcid.sync-publications', ['DISABLED', 'ALL'], 'DISABLED'); - this.currentSyncFunding = this.getCurrentPreference('dspace.orcid.sync-fundings', ['DISABLED', 'ALL'], 'DISABLED'); + this.#researcherProfile$ = + this.#item$.pipe( + switchMap(item => + this.researcherProfileService.findByRelatedItem(item) + .pipe( + getFirstCompletedRemoteData(), + catchError(createFailedRemoteDataObjectFromError$), + getRemoteDataPayload(), + ), + ), + takeUntil(this.#destroy$), + ); } /** @@ -166,37 +205,84 @@ export class OrcidSyncSettingsComponent implements OnInit { return; } - this.researcherProfileService.findByRelatedItem(this.item).pipe( - getFirstCompletedRemoteData(), - switchMap((profileRD: RemoteData) => { - if (profileRD.hasSucceeded) { - return this.researcherProfileService.patch(profileRD.payload, operations).pipe( - getFirstCompletedRemoteData(), - ); + this.#researcherProfile$ + .pipe( + switchMap(researcherProfile => this.researcherProfileService.patch(researcherProfile, operations)), + getFirstCompletedRemoteData(), + catchError(createFailedRemoteDataObjectFromError$), + take(1), + ) + .subscribe((remoteData: RemoteData) => { + if (remoteData.hasFailed) { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.error')); } else { - return of(profileRD); + this.notificationsService.success(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.success')); + this.settingsUpdated.emit(); } - }), - ).subscribe((remoteData: RemoteData) => { - if (remoteData.isSuccess) { - this.notificationsService.success(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.success')); - this.settingsUpdated.emit(); - } else { - this.notificationsService.error(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.error')); - } - }); + }); + } + + /** + * + * Handles subscriptions to populate sync preferences + * + * @param item observable that emits update on item changes + * @private + */ + private updateSyncPreferences(item: Observable) { + item.pipe( + filter(hasValue), + map(i => this.getCurrentPreference(i, 'dspace.orcid.sync-mode', ['BATCH', 'MANUAL'], 'MANUAL')), + takeUntil(this.#destroy$), + ).subscribe(val => this.currentSyncMode = val); + item.pipe( + filter(hasValue), + map(i => this.getCurrentPreference(i, 'dspace.orcid.sync-publications', ['DISABLED', 'ALL'], 'DISABLED')), + takeUntil(this.#destroy$), + ).subscribe(val => this.currentSyncPublications = val); + item.pipe( + filter(hasValue), + map(i => this.getCurrentPreference(i, 'dspace.orcid.sync-fundings', ['DISABLED', 'ALL'], 'DISABLED')), + takeUntil(this.#destroy$), + ).subscribe(val => this.currentSyncFunding = val); + } + + /** + * Handles subscription to populate the {@link syncProfileOptions} field + * + * @param item observable that emits update on item changes + * @private + */ + private updateSyncProfileOptions(item: Observable) { + item.pipe( + filter(hasValue), + map(i => i.allMetadataValues('dspace.orcid.sync-profile')), + map(metadata => + ['BIOGRAPHICAL', 'IDENTIFIERS'] + .map((value) => { + return { + label: this.messagePrefix + '.sync-profile.' + value.toLowerCase(), + value: value, + checked: metadata.includes(value), + }; + }), + ), + takeUntil(this.#destroy$), + ) + .subscribe(value => this.syncProfileOptions = value); } /** * Retrieve setting saved in the item's metadata * + * @param item The item from which retrieve settings * @param metadataField The metadata name that contains setting * @param allowedValues The allowed values * @param defaultValue The default value * @private */ - private getCurrentPreference(metadataField: string, allowedValues: string[], defaultValue: string): string { - const currentPreference = this.item.firstMetadataValue(metadataField); + private getCurrentPreference(item: Item, metadataField: string, allowedValues: string[], defaultValue: string): string { + const currentPreference = item.firstMetadataValue(metadataField); return (currentPreference && allowedValues.includes(currentPreference)) ? currentPreference : defaultValue; } @@ -216,3 +302,4 @@ export class OrcidSyncSettingsComponent implements OnInit { } } + diff --git a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts index 979b79eafc..2e17d09fc1 100644 --- a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -15,8 +15,10 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { ItemPageAbstractFieldComponent } from './item-page-abstract-field.component'; @@ -38,6 +40,7 @@ describe('ItemPageAbstractFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageAbstractFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts index 1f38317aca..a9dfe998bc 100644 --- a/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/author/item-page-author-field.component.spec.ts @@ -15,9 +15,11 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { ActivatedRouteStub } from '../../../../../shared/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; @@ -41,6 +43,7 @@ describe('ItemPageAuthorFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts index 23e4492602..264ae5ddbc 100644 --- a/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/date/item-page-date-field.component.spec.ts @@ -15,9 +15,11 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { ActivatedRouteStub } from '../../../../../shared/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; @@ -41,8 +43,8 @@ describe('ItemPageDateFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, - ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageDateFieldComponent, { diff --git a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts index f46b8fb0f8..a1ac655660 100644 --- a/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component.spec.ts @@ -15,9 +15,11 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { ActivatedRouteStub } from '../../../../../shared/testing/active-router.stub'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; @@ -43,6 +45,7 @@ describe('GenericItemPageFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts index da1320370a..18c02d95f6 100644 --- a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts @@ -14,8 +14,10 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { GenericItemPageFieldComponent } from '../generic/generic-item-page-field.component'; @@ -49,6 +51,7 @@ describe('ItemPageImgFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }) diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts index 5815f18180..6d654effe7 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.spec.ts @@ -16,6 +16,7 @@ import { import { APP_CONFIG } from '../../../../../config/app-config.interface'; import { environment } from '../../../../../environments/environment'; +import { BrowseService } from '../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; import { Item } from '../../../../core/shared/item.model'; import { MathService } from '../../../../core/shared/math.service'; @@ -26,6 +27,7 @@ import { import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../shared/testing/browse-service.stub'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { MarkdownDirective } from '../../../../shared/utils/markdown.directive'; import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component'; @@ -66,6 +68,7 @@ describe('ItemPageFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: appConfig }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, { provide: MathService, useValue: {} }, ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts index 58521569f1..fd3506c91e 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -3,17 +3,26 @@ import { Component, Input, } from '@angular/core'; +import intersectionWith from 'lodash/intersectionWith'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + filter, + mergeAll, + take, +} from 'rxjs/operators'; +import { BrowseService } from '../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; import { BrowseDefinition } from '../../../../core/shared/browse-definition.model'; import { Item } from '../../../../core/shared/item.model'; -import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { + getFirstCompletedRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, +} from '../../../../core/shared/operators'; import { MetadataValuesComponent } from '../../../field-components/metadata-values/metadata-values.component'; import { ImageField } from './image-field'; - /** * This component can be used to represent metadata on a simple item page. * It expects one input parameter of type Item to which the metadata belongs. @@ -30,7 +39,8 @@ import { ImageField } from './image-field'; }) export class ItemPageFieldComponent { - constructor(protected browseDefinitionDataService: BrowseDefinitionDataService) { + constructor(protected browseDefinitionDataService: BrowseDefinitionDataService, + protected browseService: BrowseService) { } /** @@ -74,9 +84,26 @@ export class ItemPageFieldComponent { * link in dspace.cfg (webui.browse.link.) */ get browseDefinition(): Observable { - return this.browseDefinitionDataService.findByFields(this.fields).pipe( + return this.browseService.getBrowseDefinitions().pipe( getFirstCompletedRemoteData(), - map((def) => def.payload), + getRemoteDataPayload(), + getPaginatedListPayload(), + mergeAll(), + filter((def: BrowseDefinition) => + intersectionWith(def.metadataKeys, this.fields, ItemPageFieldComponent.fieldMatch).length > 0, + ), + take(1), ); } + + /** + * Returns true iff the spec and field match. + * @param spec Specification of a metadata field name: either a metadata field, or a prefix ending in ".*". + * @param field A metadata field name. + * @private + */ + private static fieldMatch(spec: string, field: string): boolean { + return field === spec + || (spec.endsWith('.*') && field.substring(0, spec.length - 1) === spec.substring(0, spec.length - 1)); + } } diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 4b86504789..53edcab28e 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -14,8 +14,10 @@ import { import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; +import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; @@ -40,6 +42,7 @@ describe('ItemPageUriFieldComponent', () => { providers: [ { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: BrowseService, useValue: BrowseServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageUriFieldComponent, { diff --git a/src/app/menu-resolver.service.ts b/src/app/menu-resolver.service.ts index 144cad4547..683111b32a 100644 --- a/src/app/menu-resolver.service.ts +++ b/src/app/menu-resolver.service.ts @@ -7,7 +7,9 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { combineLatest, combineLatest as observableCombineLatest, + mergeMap, Observable, + of as observableOf, } from 'rxjs'; import { filter, @@ -17,6 +19,7 @@ import { } from 'rxjs/operators'; import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths'; +import { AuthService } from './core/auth/auth.service'; import { BrowseService } from './core/browse/browse.service'; import { ConfigurationDataService } from './core/data/configuration-data.service'; import { AuthorizationDataService } from './core/data/feature-authorization/authorization-data.service'; @@ -62,6 +65,7 @@ export class MenuResolverService { protected modalService: NgbModal, protected scriptDataService: ScriptDataService, protected configurationDataService: ConfigurationDataService, + protected authService: AuthService, ) { } @@ -71,7 +75,7 @@ export class MenuResolverService { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return combineLatest([ this.createPublicMenu$(), - this.createAdminMenu$(), + this.createAdminMenuIfLoggedIn$(), ]).pipe( map((menusDone: boolean[]) => menusDone.every(Boolean)), ); @@ -147,6 +151,15 @@ export class MenuResolverService { return this.waitForMenu$(MenuID.PUBLIC); } + /** + * Initialize all menu sections and items for {@link MenuID.ADMIN}, only if the user is logged in. + */ + createAdminMenuIfLoggedIn$() { + return this.authService.isAuthenticated().pipe( + mergeMap((isAuthenticated) => isAuthenticated ? this.createAdminMenu$() : observableOf(true)), + ); + } + /** * Initialize all menu sections and items for {@link MenuID.ADMIN} */ @@ -156,8 +169,6 @@ export class MenuResolverService { this.createExportMenuSections(); this.createImportMenuSections(); this.createAccessControlMenuSections(); - this.createReportMenuSections(); - return this.waitForMenu$(MenuID.ADMIN); } diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts index 386bbb0cae..720c071104 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -26,7 +26,9 @@ import { ConfigurationDataServiceStub } from './shared/testing/configuration-dat import { MenuServiceStub } from './shared/testing/menu-service.stub'; import { createPaginatedList } from './shared/testing/utils.test'; import createSpy = jasmine.createSpy; +import { AuthService } from './core/auth/auth.service'; import { MenuResolverService } from './menu-resolver.service'; +import { AuthServiceStub } from './shared/testing/auth-service.stub'; const BOOLEAN = { t: true, f: false }; const MENU_STATE = { @@ -80,6 +82,7 @@ describe('menuResolver', () => { { provide: ScriptDataService, useValue: scriptService }, { provide: ConfigurationDataService, useValue: configurationDataService }, { provide: NgbModal, useValue: mockNgbModal }, + { provide: AuthService, useValue: AuthServiceStub }, MenuResolverService, ], schemas: [NO_ERRORS_SCHEMA], @@ -94,19 +97,19 @@ describe('menuResolver', () => { describe('resolve', () => { it('should create all menus', (done) => { spyOn(resolver, 'createPublicMenu$').and.returnValue(observableOf(true)); - spyOn(resolver, 'createAdminMenu$').and.returnValue(observableOf(true)); + spyOn(resolver, 'createAdminMenuIfLoggedIn$').and.returnValue(observableOf(true)); resolver.resolve(null, null).subscribe(resolved => { expect(resolved).toBeTrue(); expect(resolver.createPublicMenu$).toHaveBeenCalled(); - expect(resolver.createAdminMenu$).toHaveBeenCalled(); + expect(resolver.createAdminMenuIfLoggedIn$).toHaveBeenCalled(); done(); }); }); it('should return an Observable that emits true as soon as all menus are created', () => { spyOn(resolver, 'createPublicMenu$').and.returnValue(cold('--(t|)', BOOLEAN)); - spyOn(resolver, 'createAdminMenu$').and.returnValue(cold('----(t|)', BOOLEAN)); + spyOn(resolver, 'createAdminMenuIfLoggedIn$').and.returnValue(cold('----(t|)', BOOLEAN)); expect(resolver.resolve(null, null)).toBeObservable(cold('----(t|)', BOOLEAN)); }); diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.html b/src/app/notifications/qa/events/quality-assurance-events.component.html index 4341764d1c..8a9f40e2b2 100644 --- a/src/app/notifications/qa/events/quality-assurance-events.component.html +++ b/src/app/notifications/qa/events/quality-assurance-events.component.html @@ -1,11 +1,11 @@
-

+

{{'notifications.events.title'| translate}}
-

+ @@ -17,9 +17,9 @@
-

+

{{'quality-assurance.events.topic' | translate}} {{this.showTopic}} -

+ @@ -247,7 +247,7 @@