diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2680420a21..52f20470a3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,8 @@ jobs: #CHROME_VERSION: "90.0.4430.212-1" # Bump Node heap size (OOM in CI after upgrading to Angular 15) NODE_OPTIONS: '--max-old-space-size=4096' + # Project name to use when running docker-compose prior to e2e tests + COMPOSE_PROJECT_NAME: 'ci' strategy: # Create a matrix of Node versions to test against (in parallel) matrix: diff --git a/config/config.example.yml b/config/config.example.yml index 69a9ffd320f..8b010ba6ea6 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -136,7 +136,7 @@ submission: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +147,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -272,6 +294,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: @@ -285,8 +309,17 @@ item: # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 +# Community Page Config +community: + # Search tab config + searchSection: + showSidebar: true + # Collection Page Config collection: + # Search tab config + searchSection: + showSidebar: true edit: undoTimeout: 10000 # 10 seconds @@ -382,7 +415,21 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + + +# Search settings +search: + # Settings to enable/disable or configure advanced search filters. + advancedFilters: + enabled: false + # List of filters to enable in "Advanced Search" dropdown + filter: [ 'title', 'author', 'subject', 'entityType' ] diff --git a/cypress/e2e/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 07c20ad7c91..32c470231d7 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Author', () => { cy.visit('/browse/author'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 4d22420227c..7966f1c82eb 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => { cy.visit('/browse/dateissued'); // Wait for to be visible - cy.get('ds-browse-by-date-page').should('be.visible'); + cy.get('ds-browse-by-date').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-date-page'); + testA11y('ds-browse-by-date'); }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 89b791f03c4..57ca88d1032 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Subject', () => { cy.visit('/browse/subject'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index e4e027586a8..09195c30df2 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Title', () => { cy.visit('/browse/title'); // Wait for to be visible - cy.get('ds-browse-by-title-page').should('be.visible'); + cy.get('ds-browse-by-title').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-title-page'); + testA11y('ds-browse-by-title'); }); }); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts index 3e7ecf61410..63d873db3ec 100644 --- a/cypress/e2e/collection-edit.cy.ts +++ b/cypress/e2e/collection-edit.cy.ts @@ -45,7 +45,7 @@ describe('Edit Collection > Content Source tab', () => { cy.get('#externalSourceCheck').check(); // Wait for the source controls to appear - cy.get('ds-collection-source-controls').should('be.visible'); + // cy.get('ds-collection-source-controls').should('be.visible'); // Analyze entire page for accessibility issues testA11y('ds-collection-source'); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts index 43bf67ce51f..a08f8cb1987 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -6,7 +6,7 @@ describe('Collection Statistics Page', () => { it('should load if you click on "Statistics" from a Collection page', () => { cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); }); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts index ca306eff5c2..6cafed0350e 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -6,7 +6,7 @@ describe('Community Statistics Page', () => { it('should load if you click on "Statistics" from a Community page', () => { cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); }); diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index ff7dbeb852d..ece38686b93 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -5,7 +5,7 @@ import '../support/commands'; describe('Site Statistics Page', () => { it('should load if you click on "Statistics" from homepage', () => { cy.visit('/'); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.location('pathname').should('eq', '/statistics'); }); diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index b856744cba7..6caeacae8e1 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -6,7 +6,7 @@ describe('Item Statistics Page', () => { it('should load if you click on "Statistics" from an Item/Entity page', () => { cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click(); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); }); diff --git a/cypress/e2e/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index c56b98fd269..673041e9f39 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -3,31 +3,31 @@ import { testA11y } from 'cypress/support/utils'; const page = { openLoginMenu() { // Click the "Log In" dropdown menu in header - cy.get('ds-themed-navbar [data-test="login-menu"]').click(); + cy.get('ds-themed-header [data-test="login-menu"]').click(); }, openUserMenu() { // Once logged in, click the User menu in header - cy.get('ds-themed-navbar [data-test="user-menu"]').click(); + cy.get('ds-themed-header [data-test="user-menu"]').click(); }, submitLoginAndPasswordByPressingButton(email, password) { // Enter email - cy.get('ds-themed-navbar [data-test="email"]').type(email); + cy.get('ds-themed-header [data-test="email"]').type(email); // Enter password - cy.get('ds-themed-navbar [data-test="password"]').type(password); + cy.get('ds-themed-header [data-test="password"]').type(password); // Click login button - cy.get('ds-themed-navbar [data-test="login-button"]').click(); + cy.get('ds-themed-header [data-test="login-button"]').click(); }, submitLoginAndPasswordByPressingEnter(email, password) { // In opened Login modal, fill out email & password, then click Enter - cy.get('ds-themed-navbar [data-test="email"]').type(email); - cy.get('ds-themed-navbar [data-test="password"]').type(password); - cy.get('ds-themed-navbar [data-test="password"]').type('{enter}'); + cy.get('ds-themed-header [data-test="email"]').type(email); + cy.get('ds-themed-header [data-test="password"]').type(password); + cy.get('ds-themed-header [data-test="password"]').type('{enter}'); }, submitLogoutByPressingButton() { // This is the POST command that will actually log us out cy.intercept('POST', '/server/api/authn/logout').as('logout'); // Click logout button - cy.get('ds-themed-navbar [data-test="logout-button"]').click(); + cy.get('ds-themed-header [data-test="logout-button"]').click(); // Wait until above POST command responds before continuing // (This ensures next action waits until logout completes) cy.wait('@logout'); @@ -102,10 +102,10 @@ describe('Login Modal', () => { page.openLoginMenu(); // Registration link should be visible - cy.get('ds-themed-navbar [data-test="register"]').should('be.visible'); + cy.get('ds-themed-header [data-test="register"]').should('be.visible'); // Click registration link & you should go to registration page - cy.get('ds-themed-navbar [data-test="register"]').click(); + cy.get('ds-themed-header [data-test="register"]').click(); cy.location('pathname').should('eq', '/register'); cy.get('ds-register-email').should('exist'); @@ -119,10 +119,10 @@ describe('Login Modal', () => { page.openLoginMenu(); // Forgot password link should be visible - cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible'); + cy.get('ds-themed-header [data-test="forgot"]').should('be.visible'); // Click link & you should go to Forgot Password page - cy.get('ds-themed-navbar [data-test="forgot"]').click(); + cy.get('ds-themed-header [data-test="forgot"]').click(); cy.location('pathname').should('eq', '/forgot'); cy.get('ds-forgot-email').should('exist'); diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 9dd93c7a2dd..28a72bcc786 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,15 +1,15 @@ const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); + cy.get('ds-themed-header [data-test="header-search-icon"]').click(); // Fill out a query in input that appears - cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query); + cy.get('ds-themed-header [data-test="header-search-box"]').type(query); }, submitQueryByPressingEnter() { - cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}'); + cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}'); }, submitQueryByPressingIcon() { - cy.get('ds-themed-navbar [data-test="header-search-icon"]').click(); + cy.get('ds-themed-header [data-test="header-search-icon"]').click(); } }; diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 40e4974c7c7..31bc53f64d8 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -14,13 +14,8 @@ # Therefore, it should be kept in sync with that file version: "3.7" -networks: - dspacenet: - services: dspace-cli: - networks: - dspacenet: {} environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz diff --git a/docker/cli.yml b/docker/cli.yml index 223ec356b9c..cc266b186f9 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -13,7 +13,13 @@ # # Therefore, it should be kept in sync with that file version: "3.7" - +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") + # 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}" @@ -30,16 +36,12 @@ services: # solr.server: Ensure we are using the 'dspacesolr' image for Solr solr__P__server: http://dspacesolr:8983/solr volumes: - - "assetstore:/dspace/assetstore" + # Keep DSpace assetstore directory between reboots + - assetstore:/dspace/assetstore entrypoint: /dspace/bin/dspace command: help - networks: - - dspacenet tty: true stdin_open: true volumes: assetstore: - -networks: - dspacenet: diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index edbb5b07598..07993e20c62 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -33,11 +33,11 @@ services: # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb - image: dspace/dspace:latest-test networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -45,8 +45,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) @@ -70,21 +68,18 @@ services: PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: - dspacenet: + - dspacenet stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.11-slim - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -92,9 +87,6 @@ services: tty: true working_dir: /var/solr/data volumes: - # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) - # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume - - solr_configs:/opt/solr/server/solr/configsets/dspace # Keep Solr data directory between reboots - solr_data:/var/solr/data # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr @@ -103,14 +95,18 @@ services: - '-c' - | init-var-solr - precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - precreate-core search /opt/solr/server/solr/configsets/dspace/search - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: \ No newline at end of file diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index ea766600efa..e1577ec8375 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -43,7 +43,7 @@ services: depends_on: - dspacedb networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -51,8 +51,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables @@ -69,25 +67,23 @@ services: container_name: dspacedb environment: PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto + image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 5432 target: 5432 stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -115,10 +111,10 @@ services: cp -r /opt/solr/server/solr/configsets/search/* search precreate-core statistics /opt/solr/server/solr/configsets/statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: diff --git a/package.json b/package.json index e5347742c8c..abd25f41482 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "@angular-eslint/eslint-plugin-template": "15.2.1", "@angular-eslint/schematics": "15.2.1", "@angular-eslint/template-parser": "15.2.1", - "@angular/cli": "^15.2.6", + "@angular/cli": "^16.0.4", "@angular/compiler-cli": "^15.2.8", "@angular/language-service": "^15.2.8", "@cypress/schematic": "^1.5.0", @@ -170,7 +170,7 @@ "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-jsdoc": "^45.0.0", "eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-unused-imports": "^2.0.0", diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html index 382caf85f46..c164cc5c319 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.html +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -1,4 +1,5 @@
+

{{ 'admin.access-control.bulk-access.title' | translate }}

diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html index 10924434369..c51ee7597c5 100644 --- a/src/app/admin/admin-import-batch-page/batch-import-page.component.html +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -1,5 +1,5 @@
- +

{{'admin.batch-import.page.header' | translate}}

{{'admin.batch-import.page.help' | translate}}

selected collection: {{getDspaceObjectName()}}  @@ -28,7 +28,7 @@

{{'admin.batch-import.page.toggle.help' | translate}} - + { + return {...route, data: { + ...route.data, + relatedRoutes: moduleRoutes.filter(relatedRoute => relatedRoute.path !== route.path) + .map((relatedRoute) => { + return {path: relatedRoute.path, data: relatedRoute.data}; + }) + }}; + })) + ] +}) +export class AdminLdnServicesRoutingModule { + +} diff --git a/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts b/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts new file mode 100644 index 00000000000..45ec696cd30 --- /dev/null +++ b/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminLdnServicesRoutingModule } from './admin-ldn-services-routing.module'; +import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component'; +import { SharedModule } from '../../shared/shared.module'; +import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component'; +import { FormsModule } from '@angular/forms'; +import { LdnItemfiltersService } from './ldn-services-data/ldn-itemfilters-data.service'; + + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + AdminLdnServicesRoutingModule, + FormsModule + ], + declarations: [ + LdnServicesOverviewComponent, + LdnServiceFormComponent, + ], + providers: [LdnItemfiltersService] +}) +export class AdminLdnServicesModule { +} diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html new file mode 100644 index 00000000000..c11a41d8878 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html @@ -0,0 +1,310 @@ +
+
+
+

{{ isNewService ? ('ldn-create-service.title' | translate) : ('ldn-edit-registered-service.title' | translate) }}

+
+ +
+ +
+ +
+
+
+
+
+ +
+ + +
+ {{ 'ldn-new-service.form.error.name' | translate }} +
+
+ + +
+ + +
+ +
+ +
+
+ + +
+ {{ 'ldn-new-service.form.error.url' | translate }} +
+
+ +
+ + +
+ {{ 'ldn-new-service.form.error.score' | translate }} +
+
+
+
+ + +
+ +
+ + +
+
+ {{ 'ldn-new-service.form.error.ipRange' | translate }} +
+
+ {{ 'ldn-new-service.form.hint.ipRange' | translate }} +
+
+ + +
+ + +
+ {{ 'ldn-new-service.form.error.ldnurl' | translate }} +
+
+ + + +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ + +
+
+ + + + +
+
+
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + +
+
+ + + + +
+
+
+
+
+
+ + {{ 'ldn-new-service.form.label.addPattern' | translate }} + + +
+
+ + + + + + + + diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss new file mode 100644 index 00000000000..afd5c80d1cb --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss @@ -0,0 +1,143 @@ +@import '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss'; +@import '../../../shared/form/form.component.scss'; + +form { + font-size: 14px; + position: relative; +} + +input, +select { + max-width: 100%; + width: 100%; + padding: 8px; + font-size: 14px; +} + +option:not(:first-child) { + font-weight: bold; +} + +.trash-button { + width: 40px; + height: 40px; +} + +textarea { + height: 200px; + resize: none; +} + +.add-pattern-link { + color: #0048ff; + cursor: pointer; +} + +.remove-pattern-link { + color: #e34949; + cursor: pointer; + margin-left: 10px; +} + +.status-checkbox { + margin-top: 5px; +} + + +.invalid-field { + border: 1px solid red; + color: #000000; +} + +.error-text { + color: red; + font-size: 0.8em; + margin-top: 5px; +} + +.toggle-switch { + display: flex; + align-items: center; + opacity: 0.8; + position: relative; + width: 60px; + height: 30px; + background-color: #ccc; + border-radius: 15px; + cursor: pointer; + transition: background-color 0.3s; +} + +.toggle-switch.checked { + background-color: #24cc9a; +} + +.slider { + position: absolute; + width: 30px; + height: 30px; + border-radius: 50%; + background-color: #fff; + transition: transform 0.3s; +} + + +.toggle-switch .slider { + width: 22px; + height: 22px; + border-radius: 50%; + margin: 0 auto; +} + +.toggle-switch.checked .slider { + transform: translateX(30px); +} + +.toggle-switch-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + margin-top: 10px; +} + +.small-text { + font-size: 0.7em; + color: #888; +} + +.toggle-switch { + cursor: pointer; + margin-top: 3px; + margin-right: 3px +} + +.label-box { + margin-left: 11px; +} + +.label-box-2 { + margin-left: 14px; +} + +.label-box-3 { + margin-left: 5px; +} + +.submission-form-footer { + border-radius: var(--bs-card-border-radius); + bottom: 0; + background-color: var(--bs-gray-400); + padding: calc(var(--bs-spacer) / 2); + z-index: calc(var(--ds-submission-footer-z-index) + 1); +} + +.marked-for-deletion { + background-color: lighten($red, 30%); +} + +.dropdown-menu-top, .scrollable-dropdown-menu { + z-index: var(--ds-submission-footer-z-index); +} + + diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts new file mode 100644 index 00000000000..e16ff49b7e6 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts @@ -0,0 +1,241 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import {NgbDropdownModule, NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {LdnServiceFormComponent} from './ldn-service-form.component'; +import {ChangeDetectorRef, EventEmitter} from '@angular/core'; +import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import {ActivatedRoute, Router} from '@angular/router'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {PaginationService} from 'ngx-pagination'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {LdnItemfiltersService} from '../ldn-services-data/ldn-itemfilters-data.service'; +import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service'; +import {RouterStub} from '../../../shared/testing/router.stub'; +import {MockActivatedRoute} from '../../../shared/mocks/active-router.mock'; +import {NotificationsServiceStub} from '../../../shared/testing/notifications-service.stub'; +import { of as observableOf, of } from 'rxjs'; +import {RouteService} from '../../../core/services/route.service'; +import {provideMockStore} from '@ngrx/store/testing'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { By } from '@angular/platform-browser'; + +describe('LdnServiceFormEditComponent', () => { + let component: LdnServiceFormComponent; + let fixture: ComponentFixture; + + let ldnServicesService: LdnServicesService; + let ldnItemfiltersService: any; + let cdRefStub: any; + let modalService: any; + let activatedRoute: MockActivatedRoute; + + const testId = '1234'; + const routeParams = { + serviceId: testId, + }; + const routeUrlSegments = [{path: 'path'}]; + const formMockValue = { + 'id': '', + 'name': 'name', + 'description': 'description', + 'url': 'www.test.com', + 'ldnUrl': 'https://test.com', + 'lowerIp': '127.0.0.1', + 'upperIp': '100.100.100.100', + 'score': 1, + 'inboundPattern': '', + 'constraintPattern': '', + 'enabled': '', + 'type': 'ldnservice', + 'notifyServiceInboundPatterns': [ + { + 'pattern': '', + 'patternLabel': 'Select a pattern', + 'constraint': '', + 'automatic': false + } + ] + }; + + + const translateServiceStub = { + get: () => of('translated-text'), + instant: () => 'translated-text', + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter() + }; + + beforeEach(async () => { + ldnServicesService = jasmine.createSpyObj('ldnServicesService', { + create: observableOf(null), + update: observableOf(null), + findById: createSuccessfulRemoteDataObject$({}), + }); + + ldnItemfiltersService = { + findAll: () => of(['item1', 'item2']), + }; + cdRefStub = Object.assign({ + detectChanges: () => fixture.detectChanges() + }); + modalService = { + open: () => {/*comment*/ + } + }; + + + activatedRoute = new MockActivatedRoute(routeParams, routeUrlSegments); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, TranslateModule.forRoot(), NgbDropdownModule], + declarations: [LdnServiceFormComponent], + providers: [ + {provide: LdnServicesService, useValue: ldnServicesService}, + {provide: LdnItemfiltersService, useValue: ldnItemfiltersService}, + {provide: Router, useValue: new RouterStub()}, + {provide: ActivatedRoute, useValue: activatedRoute}, + {provide: ChangeDetectorRef, useValue: cdRefStub}, + {provide: NgbModal, useValue: modalService}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()}, + {provide: TranslateService, useValue: translateServiceStub}, + {provide: PaginationService, useValue: {}}, + FormBuilder, + RouteService, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LdnServiceFormComponent); + component = fixture.componentInstance; + spyOn(component, 'filterPatternObjectsAndAssignLabel').and.callFake((a) => a); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.formModel instanceof FormGroup).toBeTruthy(); + }); + + it('should init properties correctly', fakeAsync(() => { + spyOn(component, 'fetchServiceData'); + spyOn(component, 'setItemfilters'); + component.ngOnInit(); + tick(100); + expect((component as any).serviceId).toEqual(testId); + expect(component.isNewService).toBeFalsy(); + expect(component.areControlsInitialized).toBeTruthy(); + expect(component.formModel.controls.notifyServiceInboundPatterns).toBeDefined(); + expect(component.fetchServiceData).toHaveBeenCalledWith(testId); + expect(component.setItemfilters).toHaveBeenCalled(); + })); + + it('should unsubscribe on destroy', () => { + spyOn((component as any).routeSubscription, 'unsubscribe'); + component.ngOnDestroy(); + expect((component as any).routeSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should handle create service with valid form', () => { + spyOn(component, 'fetchServiceData').and.callFake((a) => a); + component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{pattern: 'patternValue'}])); + const nameInput = fixture.debugElement.query(By.css('#name')); + const descriptionInput = fixture.debugElement.query(By.css('#description')); + const urlInput = fixture.debugElement.query(By.css('#url')); + const scoreInput = fixture.debugElement.query(By.css('#score')); + const lowerIpInput = fixture.debugElement.query(By.css('#lowerIp')); + const upperIpInput = fixture.debugElement.query(By.css('#upperIp')); + const ldnUrlInput = fixture.debugElement.query(By.css('#ldnUrl')); + component.formModel.patchValue(formMockValue); + + nameInput.nativeElement.value = 'testName'; + descriptionInput.nativeElement.value = 'testDescription'; + urlInput.nativeElement.value = 'tetsUrl.com'; + ldnUrlInput.nativeElement.value = 'tetsLdnUrl.com'; + scoreInput.nativeElement.value = 1; + lowerIpInput.nativeElement.value = '127.0.0.1'; + upperIpInput.nativeElement.value = '127.0.0.1'; + + fixture.detectChanges(); + + expect(component.formModel.valid).toBeTruthy(); + }); + + it('should handle create service with invalid form', () => { + const nameInput = fixture.debugElement.query(By.css('#name')); + + nameInput.nativeElement.value = 'testName'; + fixture.detectChanges(); + + expect(component.formModel.valid).toBeFalsy(); + }); + + it('should not create service with invalid form', () => { + spyOn(component.formModel, 'markAllAsTouched'); + spyOn(component, 'closeModal'); + component.createService(); + + expect(component.formModel.markAllAsTouched).toHaveBeenCalled(); + expect(component.closeModal).toHaveBeenCalled(); + }); + + it('should create service with valid form', () => { + spyOn(component.formModel, 'markAllAsTouched'); + spyOn(component, 'closeModal'); + spyOn(component, 'checkPatterns').and.callFake(() => true); + component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{pattern: 'patternValue'}])); + component.formModel.patchValue(formMockValue); + component.createService(); + + expect(component.formModel.markAllAsTouched).toHaveBeenCalled(); + expect(component.closeModal).not.toHaveBeenCalled(); + expect(ldnServicesService.create).toHaveBeenCalled(); + }); + + it('should check patterns', () => { + const arrValid = new FormArray([ + new FormGroup({ + pattern: new FormControl('pattern') + }), + ]); + + const arrInvalid = new FormArray([ + new FormGroup({ + pattern: new FormControl('') + }), + ]); + + expect(component.checkPatterns(arrValid)).toBeTruthy(); + expect(component.checkPatterns(arrInvalid)).toBeFalsy(); + }); + + it('should fetch service data', () => { + component.fetchServiceData(testId); + expect(ldnServicesService.findById).toHaveBeenCalledWith(testId); + expect(component.filterPatternObjectsAndAssignLabel).toHaveBeenCalled(); + expect((component as any).ldnService).toEqual({}); + }); + + it('should generate patch operations', () => { + spyOn(component as any, 'createReplaceOperation'); + spyOn(component as any, 'handlePatterns'); + component.generatePatchOperations(); + expect((component as any).createReplaceOperation).toHaveBeenCalledTimes(7); + expect((component as any).handlePatterns).toHaveBeenCalled(); + }); + + it('should open modal on submit', () => { + spyOn(component, 'openConfirmModal'); + component.onSubmit(); + expect(component.openConfirmModal).toHaveBeenCalled(); + }); + + + it('should reset form and leave', () => { + spyOn(component as any, 'sendBack'); + + component.resetFormAndLeave(); + expect((component as any).sendBack).toHaveBeenCalled(); + }); +}); diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts new file mode 100644 index 00000000000..0a08264bda3 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts @@ -0,0 +1,557 @@ +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild +} from '@angular/core'; +import {FormArray, FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type'; +import {ActivatedRoute, Router} from '@angular/router'; +import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service'; +import {notifyPatterns} from '../ldn-services-patterns/ldn-service-coar-patterns'; +import {animate, state, style, transition, trigger} from '@angular/animations'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {TranslateService} from '@ngx-translate/core'; +import {LdnService} from '../ldn-services-model/ldn-services.model'; +import {RemoteData} from 'src/app/core/data/remote-data'; +import {Operation} from 'fast-json-patch'; +import {getFirstCompletedRemoteData} from '../../../core/shared/operators'; +import {LdnItemfiltersService} from '../ldn-services-data/ldn-itemfilters-data.service'; +import {Itemfilter} from '../ldn-services-model/ldn-service-itemfilters'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; +import {combineLatestWith, Observable, Subscription} from 'rxjs'; +import {PaginationService} from '../../../core/pagination/pagination.service'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; +import {NotifyServicePattern} from '../ldn-services-model/ldn-service-patterns.model'; +import { IpV4Validator } from '../../../shared/utils/ipV4.validator'; + + +/** + * Component for editing LDN service through a form that allows to create or edit the properties of a service + */ +@Component({ + selector: 'ds-ldn-service-form', + templateUrl: './ldn-service-form.component.html', + styleUrls: ['./ldn-service-form.component.scss'], + animations: [ + trigger('toggleAnimation', [ + state('true', style({})), + state('false', style({})), + transition('true <=> false', animate('300ms ease-in')), + ]), + ], +}) +export class LdnServiceFormComponent implements OnInit, OnDestroy { + formModel: FormGroup; + + @ViewChild('confirmModal', {static: true}) confirmModal: TemplateRef; + @ViewChild('resetFormModal', {static: true}) resetFormModal: TemplateRef; + + public inboundPatterns: string[] = notifyPatterns; + public isNewService: boolean; + public areControlsInitialized: boolean; + public itemfiltersRD$: Observable>>; + public config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 20 + }); + public markedForDeletionInboundPattern: number[] = []; + public selectedInboundPatterns: string[]; + + protected serviceId: string; + + private deletedInboundPatterns: number[] = []; + private modalRef: any; + private ldnService: LdnService; + private selectPatternDefaultLabeli18Key = 'ldn-service.form.label.placeholder.default-select'; + private routeSubscription: Subscription; + + constructor( + protected ldnServicesService: LdnServicesService, + private ldnItemfiltersService: LdnItemfiltersService, + private formBuilder: FormBuilder, + private router: Router, + private route: ActivatedRoute, + private cdRef: ChangeDetectorRef, + protected modalService: NgbModal, + private notificationService: NotificationsService, + private translateService: TranslateService, + protected paginationService: PaginationService + ) { + + this.formModel = this.formBuilder.group({ + id: [''], + name: ['', Validators.required], + description: [''], + url: ['', Validators.required], + ldnUrl: ['', Validators.required], + lowerIp: ['', [Validators.required, new IpV4Validator()]], + upperIp: ['', [Validators.required, new IpV4Validator()]], + score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''], + constraintPattern: [''], + enabled: [''], + type: LDN_SERVICE.value, + }); + } + + ngOnInit(): void { + this.routeSubscription = this.route.params.pipe( + combineLatestWith(this.route.url) + ).subscribe(([params, segment]) => { + this.serviceId = params.serviceId; + this.isNewService = segment[0].path === 'new'; + this.formModel.addControl('notifyServiceInboundPatterns', this.formBuilder.array([this.createInboundPatternFormGroup()])); + this.areControlsInitialized = true; + if (this.serviceId && !this.isNewService) { + this.fetchServiceData(this.serviceId); + } + }); + this.setItemfilters(); + } + + ngOnDestroy(): void { + this.routeSubscription.unsubscribe(); + } + + /** + * Sets item filters using LDN item filters service + */ + setItemfilters() { + this.itemfiltersRD$ = this.ldnItemfiltersService.findAll().pipe( + getFirstCompletedRemoteData()); + } + + /** + * Handles the creation of an LDN service by retrieving and validating form fields, + * and submitting the form data to the LDN services endpoint. + */ + createService() { + this.formModel.markAllAsTouched(); + const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray; + const hasInboundPattern = notifyServiceInboundPatterns?.length > 0 ? this.checkPatterns(notifyServiceInboundPatterns) : false; + + if (this.formModel.invalid) { + this.closeModal(); + return; + } + + if (!hasInboundPattern) { + this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title')); + this.closeModal(); + return; + } + + + this.formModel.value.notifyServiceInboundPatterns = this.formModel.value.notifyServiceInboundPatterns.map((pattern: { + pattern: string; + patternLabel: string, + constraintFormatted: string; + }) => { + const {patternLabel, ...rest} = pattern; + delete rest.constraintFormatted; + return rest; + }); + + const values = {...this.formModel.value, enabled: true}; + + const ldnServiceData = this.ldnServicesService.create(values); + + ldnServiceData.pipe( + getFirstCompletedRemoteData() + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationService.success(this.translateService.get('ldn-service-notification.created.success.title'), + this.translateService.get('ldn-service-notification.created.success.body')); + this.closeModal(); + this.sendBack(); + } else { + this.notificationService.error(this.translateService.get('ldn-service-notification.created.failure.title'), + this.translateService.get('ldn-service-notification.created.failure.body')); + this.closeModal(); + } + }); + } + + /** + * Checks if at least one pattern in the specified form array has a value. + * + * @param {FormArray} formArray - The form array containing patterns to check. + * @returns {boolean} - True if at least one pattern has a value, otherwise false. + */ + checkPatterns(formArray: FormArray): boolean { + for (let i = 0; i < formArray.length; i++) { + const pattern = formArray.at(i).get('pattern').value; + if (pattern) { + return true; + } + } + return false; + } + + /** + * Fetches LDN service data by ID and updates the form + * @param serviceId - The ID of the LDN service + */ + fetchServiceData(serviceId: string): void { + this.ldnServicesService.findById(serviceId).pipe( + getFirstCompletedRemoteData() + ).subscribe( + (data: RemoteData) => { + if (data.hasSucceeded) { + this.ldnService = data.payload; + this.formModel.patchValue({ + id: this.ldnService.id, + name: this.ldnService.name, + description: this.ldnService.description, + url: this.ldnService.url, + score: this.ldnService.score, + ldnUrl: this.ldnService.ldnUrl, + type: this.ldnService.type, + enabled: this.ldnService.enabled, + lowerIp: this.ldnService.lowerIp, + upperIp: this.ldnService.upperIp + }); + this.filterPatternObjectsAndAssignLabel('notifyServiceInboundPatterns'); + let notifyServiceInboundPatternsFormArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray; + notifyServiceInboundPatternsFormArray.controls.forEach( + control => { + const controlFormGroup = control as FormGroup; + const controlConstraint = controlFormGroup.get('constraint').value; + controlFormGroup.patchValue({ + constraintFormatted: controlConstraint ? this.translateService.instant((controlConstraint as string) + '.label') : '' + }); + } + ); + } + }, + ); + } + + /** + * Filters pattern objects, initializes form groups, assigns labels, and adds them to the specified form array so the correct string is shown in the dropdown.. + * @param formArrayName - The name of the form array to be populated + */ + filterPatternObjectsAndAssignLabel(formArrayName: string) { + const PatternsArray = this.formModel.get(formArrayName) as FormArray; + PatternsArray.clear(); + + let servicesToUse = this.ldnService.notifyServiceInboundPatterns; + + servicesToUse.forEach((patternObj: NotifyServicePattern) => { + let patternFormGroup; + patternFormGroup = this.initializeInboundPatternFormGroup(); + const newPatternObjWithLabel = Object.assign(new NotifyServicePattern(), { + ...patternObj, + patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternObj?.pattern + '.label') + }); + patternFormGroup.patchValue(newPatternObjWithLabel); + + PatternsArray.push(patternFormGroup); + this.cdRef.detectChanges(); + }); + } + + /** + * Generates an array of patch operations based on form changes + * @returns Array of patch operations + */ + generatePatchOperations(): any[] { + const patchOperations: any[] = []; + + this.createReplaceOperation(patchOperations, 'name', '/name'); + this.createReplaceOperation(patchOperations, 'description', '/description'); + this.createReplaceOperation(patchOperations, 'ldnUrl', '/ldnurl'); + this.createReplaceOperation(patchOperations, 'url', '/url'); + this.createReplaceOperation(patchOperations, 'score', '/score'); + this.createReplaceOperation(patchOperations, 'lowerIp', '/lowerIp'); + this.createReplaceOperation(patchOperations, 'upperIp', '/upperIp'); + + this.handlePatterns(patchOperations, 'notifyServiceInboundPatterns'); + this.deletedInboundPatterns.forEach(index => { + const removeOperation: Operation = { + op: 'remove', + path: `notifyServiceInboundPatterns[${index}]` + }; + patchOperations.push(removeOperation); + }); + + return patchOperations; + } + + /** + * Submits the form by opening the confirmation modal + */ + onSubmit() { + this.openConfirmModal(this.confirmModal); + } + + /** + * Adds a new inbound pattern form group to the array of inbound patterns in the form + */ + addInboundPattern() { + const notifyServiceInboundPatternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray; + notifyServiceInboundPatternsArray.push(this.createInboundPatternFormGroup()); + } + + /** + * Selects an inbound pattern by updating its values based on the provided pattern value and index + * @param patternValue - The selected pattern value + * @param index - The index of the inbound pattern in the array + */ + selectInboundPattern(patternValue: string, index: number): void { + const patternArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray); + patternArray.controls[index].patchValue({pattern: patternValue}); + patternArray.controls[index].patchValue({patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternValue + '.label')}); + } + + /** + * Selects an inbound item filter by updating its value based on the provided filter value and index + * @param filterValue - The selected filter value + * @param index - The index of the inbound pattern in the array + */ + selectInboundItemFilter(filterValue: string, index: number): void { + const filterArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray); + filterArray.controls[index].patchValue({ + constraint: filterValue, + constraintFormatted: this.translateService.instant((filterValue !== '' ? filterValue : 'ldn.no-filter') + '.label') + }); + filterArray.markAllAsTouched(); + } + + /** + * Toggles the automatic property of an inbound pattern at the specified index + * @param i - The index of the inbound pattern in the array + */ + toggleAutomatic(i: number) { + const automaticControl = this.formModel.get(`notifyServiceInboundPatterns.${i}.automatic`); + if (automaticControl) { + automaticControl.markAsTouched(); + automaticControl.setValue(!automaticControl.value); + } + } + + /** + * Toggles the enabled status of the LDN service by sending a patch request + */ + toggleEnabled() { + const newStatus = !this.formModel.get('enabled').value; + + const patchOperation: Operation = { + op: 'replace', + path: '/enabled', + value: newStatus, + }; + + this.ldnServicesService.patch(this.ldnService, [patchOperation]).pipe( + getFirstCompletedRemoteData() + ).subscribe( + () => { + this.formModel.get('enabled').setValue(newStatus); + this.cdRef.detectChanges(); + } + ); + } + + /** + * Closes the modal + */ + closeModal() { + this.modalRef.close(); + this.cdRef.detectChanges(); + } + + /** + * Opens a confirmation modal with the specified content + * @param content - The content to be displayed in the modal + */ + openConfirmModal(content) { + this.modalRef = this.modalService.open(content); + } + + /** + * Patches the LDN service by retrieving and sending patch operations geenrated in generatePatchOperations() + */ + patchService() { + this.deleteMarkedInboundPatterns(); + + const patchOperations = this.generatePatchOperations(); + this.formModel.markAllAsTouched(); + // If the form is invalid, close the modal and return + if (this.formModel.invalid) { + this.closeModal(); + return; + } + + const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray; + const deletedInboundPatternsLength = this.deletedInboundPatterns.length; + // If no inbound patterns are specified, close the modal and return + // notify the user that no patterns are specified + if (notifyServiceInboundPatterns.length === deletedInboundPatternsLength) { + this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title')); + this.deletedInboundPatterns = []; + this.closeModal(); + return; + } + + this.ldnServicesService.patch(this.ldnService, patchOperations).pipe( + getFirstCompletedRemoteData() + ).subscribe( + (rd: RemoteData) => { + if (rd.hasSucceeded) { + this.closeModal(); + this.sendBack(); + this.notificationService.success(this.translateService.get('admin.registries.services-formats.modify.success.head'), + this.translateService.get('admin.registries.services-formats.modify.success.content')); + } else { + this.notificationService.error(this.translateService.get('admin.registries.services-formats.modify.failure.head'), + this.translateService.get('admin.registries.services-formats.modify.failure.content')); + this.closeModal(); + } + }); + } + + /** + * Resets the form and navigates back to the LDN services page + */ + resetFormAndLeave() { + this.sendBack(); + } + + /** + * Marks the specified inbound pattern for deletion + * @param index - The index of the inbound pattern in the array + */ + markForInboundPatternDeletion(index: number) { + if (!this.markedForDeletionInboundPattern.includes(index)) { + this.markedForDeletionInboundPattern.push(index); + } + } + + /** + * Unmarks the specified inbound pattern for deletion + * @param index - The index of the inbound pattern in the array + */ + unmarkForInboundPatternDeletion(index: number) { + const i = this.markedForDeletionInboundPattern.indexOf(index); + if (i !== -1) { + this.markedForDeletionInboundPattern.splice(i, 1); + } + } + + /** + * Deletes marked inbound patterns from the form model + */ + deleteMarkedInboundPatterns() { + this.markedForDeletionInboundPattern.sort((a, b) => b - a); + const patternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray; + + for (const index of this.markedForDeletionInboundPattern) { + if (index >= 0 && index < patternsArray.length) { + const patternGroup = patternsArray.at(index) as FormGroup; + const patternValue = patternGroup.value; + if (patternValue.isNew) { + patternsArray.removeAt(index); + } else { + this.deletedInboundPatterns.push(index); + } + } + } + + this.markedForDeletionInboundPattern = []; + } + + /** + * Creates a replace operation and adds it to the patch operations if the form control is dirty + * @param patchOperations - The array to store patch operations + * @param formControlName - The name of the form control + * @param path - The JSON Patch path for the operation + */ + private createReplaceOperation(patchOperations: any[], formControlName: string, path: string): void { + if (this.formModel.get(formControlName).dirty) { + patchOperations.push({ + op: 'replace', + path, + value: this.formModel.get(formControlName).value.toString(), + }); + } + } + + /** + * Handles patterns in the form array, checking if an add or replace operations is required + * @param patchOperations - The array to store patch operations + * @param formArrayName - The name of the form array + */ + private handlePatterns(patchOperations: any[], formArrayName: string): void { + const patternsArray = this.formModel.get(formArrayName) as FormArray; + + for (let i = 0; i < patternsArray.length; i++) { + const patternGroup = patternsArray.at(i) as FormGroup; + + const patternValue = patternGroup.value; + delete patternValue.constraintFormatted; + if (patternGroup.touched && patternGroup.valid) { + delete patternValue?.patternLabel; + if (patternValue.isNew) { + delete patternValue.isNew; + const addOperation = { + op: 'add', + path: `${formArrayName}/-`, + value: patternValue, + }; + patchOperations.push(addOperation); + } else { + const replaceOperation = { + op: 'replace', + path: `${formArrayName}[${i}]`, + value: patternValue, + }; + patchOperations.push(replaceOperation); + } + } + } + } + + /** + * Navigates back to the LDN services page + */ + private sendBack() { + this.router.navigateByUrl('admin/ldn/services'); + } + + /** + * Creates a form group for inbound patterns + * @returns The form group for inbound patterns + */ + private createInboundPatternFormGroup(): FormGroup { + const inBoundFormGroup = { + pattern: '', + patternLabel: this.translateService.instant(this.selectPatternDefaultLabeli18Key), + constraint: '', + constraintFormatted: '', + automatic: false, + isNew: true + }; + + if (this.isNewService) { + delete inBoundFormGroup.isNew; + } + + return this.formBuilder.group(inBoundFormGroup); + } + + /** + * Initializes an existing form group for inbound patterns + * @returns The initialized form group for inbound patterns + */ + private initializeInboundPatternFormGroup(): FormGroup { + return this.formBuilder.group({ + pattern: '', + patternLabel: '', + constraint: '', + constraintFormatted: '', + automatic: '', + }); + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts new file mode 100644 index 00000000000..d8534dde037 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts @@ -0,0 +1,111 @@ +import {LdnService} from '../ldn-services-model/ldn-services.model'; +import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type'; +import {RemoteData} from '../../../core/data/remote-data'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; +import {Observable, of} from 'rxjs'; +import {createSuccessfulRemoteDataObject$} from '../../../shared/remote-data.utils'; + +export const mockLdnService: LdnService = { + uuid: '1', + enabled: false, + score: 0, + id: 1, + lowerIp: '192.0.2.146', + upperIp: '192.0.2.255', + name: 'Service Name', + description: 'Service Description', + url: 'Service URL', + ldnUrl: 'Service LDN URL', + notifyServiceInboundPatterns: [ + { + pattern: 'patternA', + constraint: 'itemFilterA', + automatic: 'false', + }, + { + pattern: 'patternB', + constraint: 'itemFilterB', + automatic: 'true', + }, + ], + type: LDN_SERVICE, + _links: { + self: { + href: 'http://localhost/api/ldn/ldnservices/1' + }, + }, + get self(): string { + return ''; + }, +}; + +export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnService); + + +export const mockLdnServices: LdnService[] = [{ + uuid: '1', + enabled: false, + score: 0, + id: 1, + lowerIp: '192.0.2.146', + upperIp: '192.0.2.255', + name: 'Service Name', + description: 'Service Description', + url: 'Service URL', + ldnUrl: 'Service LDN URL', + notifyServiceInboundPatterns: [ + { + pattern: 'patternA', + constraint: 'itemFilterA', + automatic: 'false', + }, + { + pattern: 'patternB', + constraint: 'itemFilterB', + automatic: 'true', + }, + ], + type: LDN_SERVICE, + _links: { + self: { + href: 'http://localhost/api/ldn/ldnservices/1' + }, + }, + get self(): string { + return ''; + }, +}, { + uuid: '2', + enabled: false, + score: 0, + id: 2, + lowerIp: '192.0.2.146', + upperIp: '192.0.2.255', + name: 'Service Name', + description: 'Service Description', + url: 'Service URL', + ldnUrl: 'Service LDN URL', + notifyServiceInboundPatterns: [ + { + pattern: 'patternA', + constraint: 'itemFilterA', + automatic: 'false', + }, + { + pattern: 'patternB', + constraint: 'itemFilterB', + automatic: 'true', + }, + ], + type: LDN_SERVICE, + _links: { + self: { + href: 'http://localhost/api/ldn/ldnservices/1' + }, + }, + get self(): string { + return ''; + }, +} +]; +export const mockLdnServicesRD$: Observable>> = of((mockLdnServices as unknown) as RemoteData>); diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts new file mode 100644 index 00000000000..b5b08817271 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts @@ -0,0 +1,89 @@ +import { TestScheduler } from 'rxjs/testing'; +import { LdnItemfiltersService } from './ldn-itemfilters-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../../core/cache/response.models'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { FindAllData } from '../../../core/data/base/find-all-data'; +import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec'; + +describe('LdnItemfiltersService test', () => { + let scheduler: TestScheduler; + let service: LdnItemfiltersService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/ldn/itemfilters`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new LdnItemfiltersService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initFindAllService = () => new LdnItemfiltersService(null, null, null, null, null) as unknown as FindAllData; + testFindAllDataImplementation(initFindAllService); + }); + + describe('get endpoint', () => { + it('should retrieve correct endpoint', (done) => { + service.getEndpoint().subscribe(() => { + expect(halService.getEndpoint).toHaveBeenCalledWith('itemfilters'); + done(); + }); + }); + }); + +}); diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts new file mode 100644 index 00000000000..15a7bcccdaa --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts @@ -0,0 +1,61 @@ +import {Injectable} from '@angular/core'; +import {dataService} from '../../../core/data/base/data-service.decorator'; +import {LDN_SERVICE_CONSTRAINT_FILTERS} from '../ldn-services-model/ldn-service.resource-type'; +import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service'; +import {FindAllData, FindAllDataImpl} from '../../../core/data/base/find-all-data'; + +import {RequestService} from '../../../core/data/request.service'; +import {RemoteDataBuildService} from '../../../core/cache/builders/remote-data-build.service'; +import {ObjectCacheService} from '../../../core/cache/object-cache.service'; +import {HALEndpointService} from '../../../core/shared/hal-endpoint.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; +import {FollowLinkConfig} from '../../../shared/utils/follow-link-config.model'; +import {Observable} from 'rxjs'; +import {RemoteData} from '../../../core/data/remote-data'; +import {Itemfilter} from '../ldn-services-model/ldn-service-itemfilters'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; + + +/** + * A service responsible for fetching/sending data from/to the REST API on the itemfilters endpoint + */ +@Injectable() +@dataService(LDN_SERVICE_CONSTRAINT_FILTERS) +export class LdnItemfiltersService extends IdentifiableDataService implements FindAllData { + private findAllData: FindAllDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('itemfilters', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Gets the endpoint URL for the itemfilters. + * + * @returns {string} - The endpoint URL. + */ + getEndpoint() { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Finds all itemfilters based on the provided options and link configurations. + * + * @param {FindListOptions} options - The options for finding a list of itemfilters. + * @param {boolean} useCachedVersionIfAvailable - Whether to use the cached version if available. + * @param {boolean} reRequestOnStale - Whether to re-request the data if it's stale. + * @param {...FollowLinkConfig[]} linksToFollow - Configurations for following specific links. + * @returns {Observable>>} - An observable of remote data containing a paginated list of itemfilters. + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts new file mode 100644 index 00000000000..9d17fc244c4 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts @@ -0,0 +1,116 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../../core/cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { FindAllData } from '../../../core/data/base/find-all-data'; +import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec'; +import { LdnServicesService } from './ldn-services-data.service'; +import { testDeleteDataImplementation } from '../../../core/data/base/delete-data.spec'; +import { DeleteData } from '../../../core/data/base/delete-data'; +import { testSearchDataImplementation } from '../../../core/data/base/search-data.spec'; +import { SearchData } from '../../../core/data/base/search-data'; +import { testPatchDataImplementation } from '../../../core/data/base/patch-data.spec'; +import { PatchData } from '../../../core/data/base/patch-data'; +import { CreateData } from '../../../core/data/base/create-data'; +import { testCreateDataImplementation } from '../../../core/data/base/create-data.spec'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { mockLdnService } from '../ldn-service-serviceMock/ldnServicesRD$-mock'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; + + +describe('LdnServicesService test', () => { + let scheduler: TestScheduler; + let service: LdnServicesService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let responseCacheEntry: RequestEntry; + + const endpointURL = `https://rest.api/rest/api/ldn/ldnservices`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new LdnServicesService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initFindAllService = () => new LdnServicesService(null, null, null, null, null) as unknown as FindAllData; + const initDeleteService = () => new LdnServicesService(null, null, null, null, null) as unknown as DeleteData; + const initSearchService = () => new LdnServicesService(null, null, null, null, null) as unknown as SearchData; + const initPatchService = () => new LdnServicesService(null, null, null, null, null) as unknown as PatchData; + const initCreateService = () => new LdnServicesService(null, null, null, null, null) as unknown as CreateData; + + testFindAllDataImplementation(initFindAllService); + testDeleteDataImplementation(initDeleteService); + testSearchDataImplementation(initSearchService); + testPatchDataImplementation(initPatchService); + testCreateDataImplementation(initCreateService); + }); + + describe('custom methods', () => { + it('should find service by inbound pattern', (done) => { + const params = [new RequestParam('pattern', 'testPattern')]; + const findListOptions = Object.assign(new FindListOptions(), {}, {searchParams: params}); + spyOn(service, 'searchBy').and.returnValue(observableOf(null)); + spyOn((service as any).searchData, 'searchBy').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([mockLdnService]))); + + service.findByInboundPattern('testPattern').subscribe(() => { + expect(service.searchBy).toHaveBeenCalledWith('byInboundPattern', findListOptions, undefined, undefined ); + done(); + }); + }); + }); + +}); diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts new file mode 100644 index 00000000000..d1541e6bd81 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts @@ -0,0 +1,217 @@ +import {Injectable} from '@angular/core'; +import {dataService} from '../../../core/data/base/data-service.decorator'; +import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type'; +import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service'; +import {FindAllData, FindAllDataImpl} from '../../../core/data/base/find-all-data'; +import {DeleteData, DeleteDataImpl} from '../../../core/data/base/delete-data'; +import {RequestService} from '../../../core/data/request.service'; +import {RemoteDataBuildService} from '../../../core/cache/builders/remote-data-build.service'; +import {ObjectCacheService} from '../../../core/cache/object-cache.service'; +import {HALEndpointService} from '../../../core/shared/hal-endpoint.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; +import {FollowLinkConfig} from '../../../shared/utils/follow-link-config.model'; +import {Observable} from 'rxjs'; +import {RemoteData} from '../../../core/data/remote-data'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; +import {NoContent} from '../../../core/shared/NoContent.model'; +import {map, take} from 'rxjs/operators'; +import {URLCombiner} from '../../../core/url-combiner/url-combiner'; +import {MultipartPostRequest} from '../../../core/data/request.models'; +import {RestRequest} from '../../../core/data/rest-request.model'; + + +import {LdnService} from '../ldn-services-model/ldn-services.model'; + +import {PatchData, PatchDataImpl} from '../../../core/data/base/patch-data'; +import {ChangeAnalyzer} from '../../../core/data/change-analyzer'; +import {Operation} from 'fast-json-patch'; +import {RestRequestMethod} from '../../../core/data/rest-request-method'; +import {CreateData, CreateDataImpl} from '../../../core/data/base/create-data'; +import {LdnServiceConstrain} from '../ldn-services-model/ldn-service.constrain.model'; +import {SearchDataImpl} from '../../../core/data/base/search-data'; +import {RequestParam} from '../../../core/cache/models/request-param.model'; + +/** + * Injectable service responsible for fetching/sending data from/to the REST API on the ldnservices endpoint. + * + * @export + * @class LdnServicesService + * @extends {IdentifiableDataService} + * @implements {FindAllData} + * @implements {DeleteData} + * @implements {PatchData} + * @implements {CreateData} + */ +@Injectable() +@dataService(LDN_SERVICE) +export class LdnServicesService extends IdentifiableDataService implements FindAllData, DeleteData, PatchData, CreateData { + createData: CreateDataImpl; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + private patchData: PatchDataImpl; + private comparator: ChangeAnalyzer; + private searchData: SearchDataImpl; + + private findByPatternEndpoint = 'byInboundPattern'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('ldnservices', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.comparator, this.responseMsToLive, this.constructIdEndpoint); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + } + + /** + * Creates an LDN service by sending a POST request to the REST API. + * + * @param {LdnService} object - The LDN service object to be created. + * @param params Array with additional params to combine with query string + * @returns {Observable>} - Observable containing the result of the creation operation. + */ + create(object: LdnService, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * Updates an LDN service by applying a set of operations through a PATCH request to the REST API. + * + * @param {LdnService} object - The LDN service object to be updated. + * @param {Operation[]} operations - The patch operations to be applied. + * @returns {Observable>} - Observable containing the result of the update operation. + */ + patch(object: LdnService, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Updates an LDN service by sending a PUT request to the REST API. + * + * @param {LdnService} object - The LDN service object to be updated. + * @returns {Observable>} - Observable containing the result of the update operation. + */ + update(object: LdnService): Observable> { + return this.patchData.update(object); + } + + /** + * Commits pending updates by sending a PATCH request to the REST API. + * + * @param {RestRequestMethod} [method] - The HTTP method to be used for the request. + */ + commitUpdates(method?: RestRequestMethod): void { + return this.patchData.commitUpdates(method); + } + + /** + * Creates a patch representing the changes made to the LDN service in the cache. + * + * @param {LdnService} object - The LDN service object for which to create the patch. + * @returns {Observable} - Observable containing the patch operations. + */ + createPatchFromCache(object: LdnService): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Retrieves all LDN services from the REST API based on the provided options. + * + * @param {FindListOptions} [options] - The options to be applied to the request. + * @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available. + * @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale. + * @param {...FollowLinkConfig[]} linksToFollow - Optional links to follow during the request. + * @returns {Observable>>} - Observable containing the result of the request. + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Retrieves LDN services based on the inbound pattern from the REST API. + * + * @param {string} pattern - The inbound pattern to be used in the search. + * @param {FindListOptions} [options] - The options to be applied to the request. + * @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available. + * @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale. + * @param {...FollowLinkConfig[]} linksToFollow - Optional links to follow during the request. + * @returns {Observable>>} - Observable containing the result of the request. + */ + findByInboundPattern(pattern: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const params = [new RequestParam('pattern', pattern)]; + const findListOptions = Object.assign(new FindListOptions(), options, {searchParams: params}); + return this.searchBy(this.findByPatternEndpoint, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Deletes an LDN service by sending a DELETE request to the REST API. + * + * @param {string} objectId - The ID of the LDN service to be deleted. + * @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion. + * @returns {Observable>} - Observable containing the result of the deletion operation. + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Deletes an LDN service by its HATEOAS link. + * + * @param {string} href - The HATEOAS link of the LDN service to be deleted. + * @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion. + * @returns {Observable>} - Observable containing the result of the deletion operation. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + public invoke(serviceName: string, serviceId: string, parameters: LdnServiceConstrain[], files: File[]): Observable> { + const requestId = this.requestService.generateRequestId(); + this.getBrowseEndpoint().pipe( + take(1), + map((endpoint: string) => new URLCombiner(endpoint, serviceName, 'processes', serviceId).toString()), + map((endpoint: string) => { + const body = this.getInvocationFormData(parameters, files); + return new MultipartPostRequest(requestId, endpoint, body); + }) + ).subscribe((request: RestRequest) => this.requestService.send(request)); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + private getInvocationFormData(constrain: LdnServiceConstrain[], files: File[]): FormData { + const form: FormData = new FormData(); + form.set('properties', JSON.stringify(constrain)); + files.forEach((file: File) => { + form.append('file', file); + }); + return form; + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html new file mode 100644 index 00000000000..0aaa39bda21 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html @@ -0,0 +1,99 @@ +
+
+

{{ 'ldn-registered-services.title' | translate }}

+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
{{ 'service.overview.table.name' | translate }}{{ 'service.overview.table.description' | translate }}{{ 'service.overview.table.status' | translate }}{{ 'service.overview.table.actions' | translate }}
{{ ldnService.name }} + + +
+ {{ ldnService.description }} +
+
+
+
+ + {{ ldnService.enabled ? ('ldn-service.overview.table.enabled' | translate) : ('ldn-service.overview.table.disabled' | translate) }} + + +
+ + +
+
+
+
+
+ + + +
+ + + + +
+
+ diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss new file mode 100644 index 00000000000..07377d63d5a --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss @@ -0,0 +1,29 @@ +.status-indicator { + padding: 2.5px 25px 2.5px 25px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.5s; +} + +.status-enabled { + background-color: #daf7a6; + color: #4f5359; + font-size: 85%; + font-weight: bold; + +} + +.status-enabled:hover { + background-color: #faa0a0; +} + +.status-disabled { + background-color: #faa0a0; + color: #4f5359; + font-size: 85%; + font-weight: bold; +} + +.status-disabled:hover { + background-color: #daf7a6; +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts new file mode 100644 index 00000000000..664edcb27dc --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts @@ -0,0 +1,144 @@ +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {ChangeDetectorRef, EventEmitter} from '@angular/core'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {NotificationsServiceStub} from '../../../shared/testing/notifications-service.stub'; +import {TranslateModule, TranslateService} from '@ngx-translate/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service'; +import {PaginationService} from '../../../core/pagination/pagination.service'; +import {PaginationServiceStub} from '../../../shared/testing/pagination-service.stub'; +import {of} from 'rxjs'; +import {LdnService} from '../ldn-services-model/ldn-services.model'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; +import {RemoteData} from '../../../core/data/remote-data'; +import {LdnServicesOverviewComponent} from './ldn-services-directory.component'; +import {createSuccessfulRemoteDataObject$} from '../../../shared/remote-data.utils'; +import {createPaginatedList} from '../../../shared/testing/utils.test'; + +describe('LdnServicesOverviewComponent', () => { + let component: LdnServicesOverviewComponent; + let fixture: ComponentFixture; + let ldnServicesService; + let paginationService; + let modalService: NgbModal; + let notificationsService: NotificationsService; + let translateService: TranslateService; + + const translateServiceStub = { + get: () => of('translated-text'), + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter() + }; + + beforeEach(async () => { + paginationService = new PaginationServiceStub(); + ldnServicesService = jasmine.createSpyObj('LdnServicesService', ['findAll', 'delete', 'patch']); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [LdnServicesOverviewComponent], + providers: [ + { + provide: LdnServicesService, + useValue: ldnServicesService + }, + {provide: PaginationService, useValue: paginationService}, + { + provide: NgbModal, useValue: { + open: () => { /*comment*/ + } + } + }, + {provide: ChangeDetectorRef, useValue: {}}, + {provide: NotificationsService, useValue: NotificationsServiceStub}, + {provide: TranslateService, useValue: translateServiceStub}, + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LdnServicesOverviewComponent); + component = fixture.componentInstance; + ldnServicesService = TestBed.inject(LdnServicesService); + paginationService = TestBed.inject(PaginationService); + modalService = TestBed.inject(NgbModal); + notificationsService = TestBed.inject(NotificationsService); + translateService = TestBed.inject(TranslateService); + component.modalRef = jasmine.createSpyObj({close: null}); + component.isProcessingSub = jasmine.createSpyObj({unsubscribe: null}); + component.ldnServicesRD$ = of({} as RemoteData>); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call setLdnServices', fakeAsync(() => { + spyOn(component, 'setLdnServices').and.callThrough(); + component.ngOnInit(); + tick(); + expect(component.setLdnServices).toHaveBeenCalled(); + })); + + it('should set ldnServicesRD$ with mock data', fakeAsync(() => { + spyOn(component, 'setLdnServices').and.callThrough(); + const testData: LdnService[] = Object.assign([new LdnService()], [ + {id: 1, name: 'Service 1', description: 'Description 1', enabled: true}, + {id: 2, name: 'Service 2', description: 'Description 2', enabled: false}, + {id: 3, name: 'Service 3', description: 'Description 3', enabled: true}]); + + const mockLdnServicesRD = createPaginatedList(testData); + component.ldnServicesRD$ = createSuccessfulRemoteDataObject$(mockLdnServicesRD); + fixture.detectChanges(); + + const tableRows = fixture.debugElement.nativeElement.querySelectorAll('tbody tr'); + expect(tableRows.length).toBe(testData.length); + const firstRowContent = tableRows[0].textContent; + expect(firstRowContent).toContain('Service 1'); + expect(firstRowContent).toContain('Description 1'); + })); + }); + + describe('ngOnDestroy', () => { + it('should call paginationService.clearPagination and unsubscribe', () => { + // spyOn(paginationService, 'clearPagination'); + // spyOn(component.isProcessingSub, 'unsubscribe'); + component.ngOnDestroy(); + expect(paginationService.clearPagination).toHaveBeenCalledWith(component.pageConfig.id); + expect(component.isProcessingSub.unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('openDeleteModal', () => { + it('should open delete modal', () => { + spyOn(modalService, 'open'); + component.openDeleteModal(component.deleteModal); + expect(modalService.open).toHaveBeenCalledWith(component.deleteModal); + }); + }); + + describe('closeModal', () => { + it('should close modal and detect changes', () => { + // spyOn(component.modalRef, 'close'); + spyOn(component.cdRef, 'detectChanges'); + component.closeModal(); + expect(component.modalRef.close).toHaveBeenCalled(); + expect(component.cdRef.detectChanges).toHaveBeenCalled(); + }); + }); + + describe('deleteSelected', () => { + it('should delete selected service and update data', fakeAsync(() => { + const serviceId = '123'; + const mockRemoteData = { /* just an empty object to retrieve as as RemoteData> */}; + spyOn(component, 'setLdnServices').and.callThrough(); + const deleteSpy = ldnServicesService.delete.and.returnValue(of(mockRemoteData as RemoteData>)); + component.selectedServiceId = serviceId; + component.deleteSelected(serviceId, ldnServicesService); + tick(); + expect(deleteSpy).toHaveBeenCalledWith(serviceId); + })); + }); +}); diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts new file mode 100644 index 00000000000..b36d102cb0a --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts @@ -0,0 +1,176 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild +} from '@angular/core'; +import {Observable, Subscription} from 'rxjs'; +import {RemoteData} from '../../../core/data/remote-data'; +import {PaginatedList} from '../../../core/data/paginated-list.model'; +import {FindListOptions} from '../../../core/data/find-list-options.model'; +import {LdnService} from '../ldn-services-model/ldn-services.model'; +import {PaginationComponentOptions} from '../../../shared/pagination/pagination-component-options.model'; +import {map, switchMap} from 'rxjs/operators'; +import {LdnServicesService} from 'src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import {PaginationService} from 'src/app/core/pagination/pagination.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {hasValue} from '../../../shared/empty.util'; +import {Operation} from 'fast-json-patch'; +import {getFirstCompletedRemoteData} from '../../../core/shared/operators'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {TranslateService} from '@ngx-translate/core'; + + +/** + * The `LdnServicesOverviewComponent` is a component that provides an overview of LDN (Linked Data Notifications) services. + * It displays a paginated list of LDN services, allows users to edit and delete services, + * toggle the status of each service directly form the page and allows for creation of new services redirecting the user on the creation/edit form + */ +@Component({ + selector: 'ds-ldn-services-directory', + templateUrl: './ldn-services-directory.component.html', + styleUrls: ['./ldn-services-directory.component.scss'], + changeDetection: ChangeDetectionStrategy.Default +}) +export class LdnServicesOverviewComponent implements OnInit, OnDestroy { + + selectedServiceId: string | number | null = null; + servicesData: any[] = []; + @ViewChild('deleteModal', {static: true}) deleteModal: TemplateRef; + ldnServicesRD$: Observable>>; + config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 10 + }); + pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'po', + pageSize: 10 + }); + isProcessingSub: Subscription; + modalRef: any; + + + constructor( + protected ldnServicesService: LdnServicesService, + protected paginationService: PaginationService, + protected modalService: NgbModal, + public cdRef: ChangeDetectorRef, + private notificationService: NotificationsService, + private translateService: TranslateService, + ) { + } + + ngOnInit(): void { + this.setLdnServices(); + } + + /** + * Sets up the LDN services by fetching and observing the paginated list of services. + */ + setLdnServices() { + this.ldnServicesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( + switchMap((config) => this.ldnServicesService.findAll(config, false, false).pipe( + getFirstCompletedRemoteData() + )) + ); + } + + ngOnDestroy(): void { + this.paginationService.clearPagination(this.pageConfig.id); + if (hasValue(this.isProcessingSub)) { + this.isProcessingSub.unsubscribe(); + } + } + + /** + * Opens the delete confirmation modal. + * + * @param {any} content - The content of the modal. + */ + openDeleteModal(content) { + this.modalRef = this.modalService.open(content); + } + + /** + * Closes the currently open modal and triggers change detection. + */ + closeModal() { + this.modalRef.close(); + this.cdRef.detectChanges(); + } + + /** + * Sets the selected LDN service ID for deletion and opens the delete confirmation modal. + * + * @param {number} serviceId - The ID of the service to be deleted. + */ + selectServiceToDelete(serviceId: number) { + this.selectedServiceId = serviceId; + this.openDeleteModal(this.deleteModal); + } + + /** + * Deletes the selected LDN service. + * + * @param {string} serviceId - The ID of the service to be deleted. + * @param {LdnServicesService} ldnServicesService - The service for managing LDN services. + */ + deleteSelected(serviceId: string, ldnServicesService: LdnServicesService): void { + if (this.selectedServiceId !== null) { + ldnServicesService.delete(serviceId).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.servicesData = this.servicesData.filter(service => service.id !== serviceId); + this.ldnServicesRD$ = this.ldnServicesRD$.pipe( + map((remoteData: RemoteData>) => { + if (remoteData.hasSucceeded) { + remoteData.payload.page = remoteData.payload.page.filter(service => service.id.toString() !== serviceId); + } + return remoteData; + }) + ); + this.cdRef.detectChanges(); + this.closeModal(); + this.notificationService.success(this.translateService.get('ldn-service-delete.notification.success.title'), + this.translateService.get('ldn-service-delete.notification.success.content')); + } else { + this.notificationService.error(this.translateService.get('ldn-service-delete.notification.error.title'), + this.translateService.get('ldn-service-delete.notification.error.content')); + this.cdRef.detectChanges(); + } + }); + } + } + + /** + * Toggles the status (enabled/disabled) of an LDN service. + * + * @param {any} ldnService - The LDN service object. + * @param {LdnServicesService} ldnServicesService - The service for managing LDN services. + */ + toggleStatus(ldnService: any, ldnServicesService: LdnServicesService): void { + const newStatus = !ldnService.enabled; + const originalStatus = ldnService.enabled; + + const patchOperation: Operation = { + op: 'replace', + path: '/enabled', + value: newStatus, + }; + + ldnServicesService.patch(ldnService, [patchOperation]).pipe(getFirstCompletedRemoteData()).subscribe( + (rd: RemoteData) => { + if (rd.hasSucceeded) { + ldnService.enabled = newStatus; + this.notificationService.success(this.translateService.get('ldn-enable-service.notification.success.title'), + this.translateService.get('ldn-enable-service.notification.success.content')); + } else { + ldnService.enabled = originalStatus; + this.notificationService.error(this.translateService.get('ldn-enable-service.notification.error.title'), + this.translateService.get('ldn-enable-service.notification.error.content')); + } + } + ); + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts new file mode 100644 index 00000000000..55b7ad8b982 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts @@ -0,0 +1,31 @@ +import {autoserialize, deserialize, inheritSerialization} from 'cerialize'; +import {LDN_SERVICE_CONSTRAINT_FILTER} from './ldn-service.resource-type'; +import {CacheableObject} from '../../../core/cache/cacheable-object.model'; +import {typedObject} from '../../../core/cache/builders/build-decorators'; +import {excludeFromEquals} from '../../../core/utilities/equals.decorators'; +import {ResourceType} from '../../../core/shared/resource-type'; + +/** A single filter value and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class Itemfilter extends CacheableObject { + static type = LDN_SERVICE_CONSTRAINT_FILTER; + + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: string; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts new file mode 100644 index 00000000000..295426ba878 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts @@ -0,0 +1,13 @@ +import {autoserialize} from 'cerialize'; + +/** + * A single notify service pattern and his properties + */ +export class NotifyServicePattern { + @autoserialize + pattern: string; + @autoserialize + constraint: string; + @autoserialize + automatic: string; +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts new file mode 100644 index 00000000000..040e4d37b8a --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts @@ -0,0 +1,8 @@ +/** + * List of services statuses + */ +export enum LdnServiceStatus { + UNKOWN, + DISABLED, + ENABLED, +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts new file mode 100644 index 00000000000..5121e47f69d --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts @@ -0,0 +1,3 @@ +export class LdnServiceConstrain { + void: any; +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts new file mode 100644 index 00000000000..4fb510c032e --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts @@ -0,0 +1,12 @@ +/** + * The resource type for Ldn-Services + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +import {ResourceType} from '../../../core/shared/resource-type'; + +export const LDN_SERVICE = new ResourceType('ldnservice'); +export const LDN_SERVICE_CONSTRAINT_FILTERS = new ResourceType('itemfilters'); + +export const LDN_SERVICE_CONSTRAINT_FILTER = new ResourceType('itemfilter'); diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts new file mode 100644 index 00000000000..9e803fbc013 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts @@ -0,0 +1,72 @@ +import {ResourceType} from '../../../core/shared/resource-type'; +import {CacheableObject} from '../../../core/cache/cacheable-object.model'; +import {autoserialize, deserialize, deserializeAs, inheritSerialization} from 'cerialize'; +import {LDN_SERVICE} from './ldn-service.resource-type'; +import {excludeFromEquals} from '../../../core/utilities/equals.decorators'; +import {typedObject} from '../../../core/cache/builders/build-decorators'; +import {NotifyServicePattern} from './ldn-service-patterns.model'; + + +/** + * LDN Services bounded to each selected pattern, relation set in service creation + */ + +export interface LdnServiceByPattern { + allowsMultipleRequests: boolean; + services: LdnService[]; +} + +/** An LdnService and its properties. */ +@typedObject +@inheritSerialization(CacheableObject) +export class LdnService extends CacheableObject { + static type = LDN_SERVICE; + + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: number; + + @deserializeAs('id') + uuid: string; + + @autoserialize + name: string; + + @autoserialize + description: string; + + @autoserialize + url: string; + + @autoserialize + score: number; + + @autoserialize + enabled: boolean; + + @autoserialize + ldnUrl: string; + + @autoserialize + lowerIp: string; + + @autoserialize + upperIp: string; + + @autoserialize + notifyServiceInboundPatterns?: NotifyServicePattern[]; + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts new file mode 100644 index 00000000000..c734503d951 --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts @@ -0,0 +1,10 @@ +/** + * List of parameter types used for scripts + */ +export enum LdnServiceConstrainType { + STRING = 'String', + DATE = 'date', + BOOLEAN = 'boolean', + FILE = 'InputStream', + OUTPUT = 'OutputStream' +} diff --git a/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts b/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts new file mode 100644 index 00000000000..faa7dc82d7f --- /dev/null +++ b/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts @@ -0,0 +1,16 @@ +/** + * All available patterns for LDN service creation. + * They are used to populate a dropdown in the LDN service form creation + */ + +export const notifyPatterns = [ + + 'request-endorsement', + + 'request-ingest', + + 'request-review', + +]; + + diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts new file mode 100644 index 00000000000..f524cd56c20 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface NotificationsSuggestionTargetsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class NotificationsSuggestionTargetsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminNotificationsSuggestionTargetsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): NotificationsSuggestionTargetsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html new file mode 100644 index 00000000000..b04e7132f17 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss similarity index 100% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss rename to src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts new file mode 100644 index 00000000000..cec8ddc00b8 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { + NotificationsSuggestionTargetsPageComponent +} from '../../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component'; + +describe('NotificationsSuggestionTargetsPageComponent', () => { + let component: NotificationsSuggestionTargetsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + NotificationsSuggestionTargetsPageComponent + ], + providers: [ + NotificationsSuggestionTargetsPageComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsSuggestionTargetsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts new file mode 100644 index 00000000000..2256a1bc36a --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-admin-notifications-publication-claim-page', + templateUrl: './admin-notifications-publication-claim-page.component.html', + styleUrls: ['./admin-notifications-publication-claim-page.component.scss'] +}) +export class AdminNotificationsPublicationClaimPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts index 2820a9a2c7b..9fcabedd647 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -1,8 +1,7 @@ -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { getNotificationsModuleRoute } from '../admin-routing-paths'; export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; -export function getQualityAssuranceRoute(id: string) { - return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); +export function getQualityAssuranceEditRoute() { + return `/${QUALITY_ASSURANCE_EDIT_PATH}`; } diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts index 63d555d7b74..c82c91233ee 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -4,30 +4,66 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { PUBLICATION_CLAIMS_PATH } from './admin-notifications-routing-paths'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; -import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; -import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; -import { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; -import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver'; -import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; -import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { + SiteAdministratorGuard +} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service'; +import { + QualityAssuranceEventsPageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver'; +import { + AdminNotificationsPublicationClaimPageResolver +} from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; +import { + QualityAssuranceTopicsPageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component'; +import { + QualityAssuranceTopicsPageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service'; +import { + QualityAssuranceSourcePageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component'; +import { + QualityAssuranceSourcePageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service'; import { SourceDataResolver -} from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver'; +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver'; +import { + QualityAssuranceEventsPageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component'; + @NgModule({ imports: [ RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${PUBLICATION_CLAIMS_PATH}`, + component: AdminNotificationsPublicationClaimPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + suggestionTargetParams: AdminNotificationsPublicationClaimPageResolver + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false + } + }, { canActivate: [ AuthenticatedGuard ], path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, - component: AdminQualityAssuranceTopicsPageComponent, + component: QualityAssuranceTopicsPageComponent, pathMatch: 'full', resolve: { breadcrumb: QualityAssuranceBreadcrumbResolver, - openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver + openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver }, data: { title: 'admin.quality-assurance.page.title', @@ -37,12 +73,27 @@ import { }, { canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/target/:targetId`, + component: QualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ SiteAdministratorGuard ], path: `${QUALITY_ASSURANCE_EDIT_PATH}`, - component: AdminQualityAssuranceSourcePageComponent, + component: QualityAssuranceSourcePageComponent, pathMatch: 'full', resolve: { breadcrumb: I18nBreadcrumbResolver, - openaireQualityAssuranceSourceParams: AdminQualityAssuranceSourcePageResolver, + openaireQualityAssuranceSourceParams: QualityAssuranceSourcePageResolver, sourceData: SourceDataResolver }, data: { @@ -54,11 +105,11 @@ import { { canActivate: [ AuthenticatedGuard ], path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, - component: AdminQualityAssuranceEventsPageComponent, + component: QualityAssuranceEventsPageComponent, pathMatch: 'full', resolve: { breadcrumb: QualityAssuranceBreadcrumbResolver, - openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver + openaireQualityAssuranceEventsParams: QualityAssuranceEventsPageResolver }, data: { title: 'admin.notifications.event.page.title', @@ -71,10 +122,12 @@ import { providers: [ I18nBreadcrumbResolver, I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, SourceDataResolver, - AdminQualityAssuranceTopicsPageResolver, - AdminQualityAssuranceEventsPageResolver, - AdminQualityAssuranceSourcePageResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceTopicsPageResolver, + QualityAssuranceEventsPageResolver, + QualityAssuranceSourcePageResolver, QualityAssuranceBreadcrumbResolver, QualityAssuranceBreadcrumbService ] diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts index 84475a1623e..ea670d222c0 100644 --- a/src/app/admin/admin-notifications/admin-notifications.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -3,10 +3,14 @@ import { NgModule } from '@angular/core'; import { CoreModule } from '../../core/core.module'; import { SharedModule } from '../../shared/shared.module'; import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; -import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; -import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; -import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; -import {NotificationsModule} from '../../notifications/notifications.module'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { NotificationsModule } from '../../notifications/notifications.module'; + + + + + + @NgModule({ imports: [ @@ -17,9 +21,7 @@ import {NotificationsModule} from '../../notifications/notifications.module'; NotificationsModule ], declarations: [ - AdminQualityAssuranceTopicsPageComponent, - AdminQualityAssuranceEventsPageComponent, - AdminQualityAssuranceSourcePageComponent + AdminNotificationsPublicationClaimPageComponent, ], entryComponents: [] }) diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts deleted file mode 100644 index 451c911c4ce..00000000000 --- a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page.component'; - -describe('AdminQualityAssuranceSourcePageComponent', () => { - let component: AdminQualityAssuranceSourcePageComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ AdminQualityAssuranceSourcePageComponent ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AdminQualityAssuranceSourcePageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create AdminQualityAssuranceSourcePageComponent', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts deleted file mode 100644 index 447e5a2e553..00000000000 --- a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; - -/** - * Component for the page that show the QA sources. - */ -@Component({ - selector: 'ds-admin-quality-assurance-source-page-component', - templateUrl: './admin-quality-assurance-source-page.component.html', -}) -export class AdminQualityAssuranceSourcePageComponent {} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts deleted file mode 100644 index a32f60f017a..00000000000 --- a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page.component'; - -describe('AdminQualityAssuranceTopicsPageComponent', () => { - let component: AdminQualityAssuranceTopicsPageComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AdminQualityAssuranceTopicsPageComponent ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(AdminQualityAssuranceTopicsPageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create AdminQualityAssuranceTopicsPageComponent', () => { - expect(component).toBeTruthy(); - }); -}); 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 37323d41d0e..894ef3dc7e7 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 @@ -19,7 +19,7 @@

{{'admin.registries.bitstream-formats - + @@ -35,6 +35,7 @@

{{'admin.registries.bitstream-formats [checked]="isSelected(bitstreamFormat) | async" (change)="selectBitStreamFormat(bitstreamFormat, $event)" > + {{'admin.registries.bitstream-formats.select' | translate}}}

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 35bffad185c..d3be5df08e6 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 @@ -2,7 +2,7 @@
{{'admin.registries.bitstream-formats.table.selected' | translate}} {{'admin.registries.bitstream-formats.table.id' | translate}} {{'admin.registries.bitstream-formats.table.name' | translate}} {{'admin.registries.bitstream-formats.table.mimetype' | translate}}{{bitstreamFormat.id}}
- + @@ -34,6 +34,7 @@ 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 a4a4613565e..85d1e90692a 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,11 +1,11 @@
-

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

+

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

-

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

+

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

-

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

+

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

-

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

+

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

{{'admin.registries.metadata.schemas.table.selected' | translate}} {{'admin.registries.metadata.schemas.table.id' | translate}} {{'admin.registries.metadata.schemas.table.namespace' | translate}} {{'admin.registries.metadata.schemas.table.name' | translate}}{{schema.id}}
- + @@ -33,12 +33,11 @@

{{'admin.registries.schema.fields.head' | translate}}

- diff --git a/src/app/admin/admin-reports/admin-reports-routing.module.ts b/src/app/admin/admin-reports/admin-reports-routing.module.ts new file mode 100644 index 00000000000..9022429502f --- /dev/null +++ b/src/app/admin/admin-reports/admin-reports-routing.module.ts @@ -0,0 +1,37 @@ +import { FilteredCollectionsComponent } from './filtered-collections/filtered-collections.component'; +import { FilteredItemsComponent } from './filtered-items/filtered-items.component'; +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: 'collections', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: {title: 'admin.reports.collections.title', breadcrumbKey: 'admin.reports.collections'}, + children: [ + { + path: '', + component: FilteredCollectionsComponent + } + ] + }, + { + path: 'queries', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: {title: 'admin.reports.items.title', breadcrumbKey: 'admin.reports.items'}, + children: [ + { + path: '', + component: FilteredItemsComponent + } + ] + } + ]) + ] +}) +export class AdminReportsRoutingModule { + +} diff --git a/src/app/admin/admin-reports/admin-reports.module.ts b/src/app/admin/admin-reports/admin-reports.module.ts new file mode 100644 index 00000000000..70dfba8a072 --- /dev/null +++ b/src/app/admin/admin-reports/admin-reports.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FilteredCollectionsComponent } from './filtered-collections/filtered-collections.component'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from '../../shared/shared.module'; +import { FormModule } from '../../shared/form/form.module'; +import { FilteredItemsComponent } from './filtered-items/filtered-items.component'; +import { AdminReportsRoutingModule } from './admin-reports-routing.module'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { FiltersComponent } from './filters-section/filters-section.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RouterModule, + FormModule, + AdminReportsRoutingModule, + NgbAccordionModule + ], + declarations: [ + FilteredCollectionsComponent, + FilteredItemsComponent, + FiltersComponent + ] +}) +export class AdminReportsModule { +} diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts new file mode 100644 index 00000000000..a48b1e02fa8 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts @@ -0,0 +1,36 @@ +export class FilteredCollection { + + public label: string; + public handle: string; + public communityLabel: string; + public communityHandle: string; + public nbTotalItems: number; + public values = {}; + public allFiltersValue: number; + + public clear() { + this.label = ''; + this.handle = ''; + this.communityLabel = ''; + this.communityHandle = ''; + this.nbTotalItems = 0; + this.values = {}; + this.allFiltersValue = 0; + } + + public deserialize(object: any) { + this.clear(); + this.label = object.label; + this.handle = object.handle; + this.communityLabel = object.community_label; + this.communityHandle = object.community_handle; + this.nbTotalItems = object.nb_total_items; + let valuesPerFilter = object.values; + for (let filter in valuesPerFilter) { + if (valuesPerFilter.hasOwnProperty(filter)) { + this.values[filter] = valuesPerFilter[filter]; + } + } + this.allFiltersValue = object.all_filters_value; + } +} diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html new file mode 100644 index 00000000000..5199a115a6e --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html @@ -0,0 +1,64 @@ +
+
{{'admin.registries.schema.fields.table.selected' | translate}} {{'admin.registries.schema.fields.table.id' | translate}} {{'admin.registries.schema.fields.table.field' | translate}} {{'admin.registries.schema.fields.table.scopenote' | translate}}
- + + {{field.id}} {{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ "admin.reports.collections.community" | translate }}{{ "admin.reports.collections.collection" | translate }}{{ "admin.reports.collections.nb_items" | translate }}{{ "admin.reports.collections.match_all_selected_filters" | translate }}{{ ("admin.reports.commons.filters." + getGroup(filter.key) + "." + filter.key) | translate }}
{{ results.summary.nbTotalItems }}{{ results.summary.allFiltersValue }}{{ filter.value }}
{{ coll.communityLabel }}{{ coll.label }}{{ coll.nbTotalItems }}{{ coll.allFiltersValue }}{{ coll.values[filter.key] || 0 }}
+ + + +

+ +
+ + diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss new file mode 100644 index 00000000000..73ce5275e5b --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss @@ -0,0 +1,3 @@ +.num { + text-align: center; +} diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts new file mode 100644 index 00000000000..fe5dc612cad --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts @@ -0,0 +1,83 @@ +import { waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock'; +import { FormBuilder } from '@angular/forms'; +import { FilteredCollectionsComponent } from './filtered-collections.component'; +import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; +import { NgbAccordion, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of as observableOf } from 'rxjs'; +import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; + +describe('FiltersComponent', () => { + let component: FilteredCollectionsComponent; + let fixture: ComponentFixture; + let formBuilder: FormBuilder; + + const expected = { + payload: { + collections: [], + summary: { + label: 'Test' + } + }, + statusCode: 200, + statusText: 'OK' + } as RawRestResponse; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [FilteredCollectionsComponent], + imports: [ + NgbAccordionModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + HttpClientTestingModule + ], + providers: [ + FormBuilder, + DspaceRestService + ], + schemas: [NO_ERRORS_SCHEMA] + }); + })); + + beforeEach(waitForAsync(() => { + formBuilder = TestBed.inject(FormBuilder); + + fixture = TestBed.createComponent(FilteredCollectionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should be displaying the filters panel initially', () => { + let accordion: NgbAccordion = component.accordionComponent; + expect(accordion.isExpanded('filters')).toBeTrue(); + }); + + describe('toggle', () => { + beforeEach(() => { + spyOn(component, 'getFilteredCollections').and.returnValue(observableOf(expected)); + spyOn(component.results, 'deserialize'); + spyOn(component.accordionComponent, 'expand').and.callThrough(); + component.submit(); + fixture.detectChanges(); + }); + + it('should be displaying the collections panel after submitting', waitForAsync(() => { + fixture.whenStable().then(() => { + expect(component.accordionComponent.expand).toHaveBeenCalledWith('collections'); + expect(component.accordionComponent.isExpanded('collections')).toBeTrue(); + }); + })); + }); +}); diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts new file mode 100644 index 00000000000..23fde052789 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts @@ -0,0 +1,70 @@ +import { Component, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'; +import { Observable } from 'rxjs'; +import { RestRequestMethod } from 'src/app/core/data/rest-request-method'; +import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; +import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; +import { environment } from 'src/environments/environment'; +import { FiltersComponent } from '../filters-section/filters-section.component'; +import { FilteredCollections } from './filtered-collections.model'; + +/** + * Component representing the Filtered Collections content report + */ +@Component({ + selector: 'ds-report-filtered-collections', + templateUrl: './filtered-collections.component.html', + styleUrls: ['./filtered-collections.component.scss'] +}) +export class FilteredCollectionsComponent { + + queryForm: FormGroup; + results: FilteredCollections = new FilteredCollections(); + @ViewChild('acc') accordionComponent: NgbAccordion; + + constructor( + private formBuilder: FormBuilder, + private restService: DspaceRestService) {} + + ngOnInit() { + this.queryForm = this.formBuilder.group({ + filters: FiltersComponent.formGroup(this.formBuilder) + }); + } + + filtersFormGroup(): FormGroup { + return this.queryForm.get('filters') as FormGroup; + } + + getGroup(filterId: string): string { + return FiltersComponent.getGroup(filterId).id; + } + + submit() { + this + .getFilteredCollections() + .subscribe( + response => { + this.results.deserialize(response.payload); + this.accordionComponent.expand('collections'); + } + ); + } + + getFilteredCollections(): Observable { + let params = this.toQueryString(); + if (params.length > 0) { + params = `?${params}`; + } + let scheme = environment.rest.ssl ? 'https' : 'http'; + let urlRestApp = `${scheme}://${environment.rest.host}:${environment.rest.port}${environment.rest.nameSpace}`; + return this.restService.request(RestRequestMethod.GET, `${urlRestApp}/api/contentreport/filteredcollections${params}`); + } + + private toQueryString(): string { + let params = FiltersComponent.toQueryString(this.queryForm.value.filters); + return params; + } + +} diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts new file mode 100644 index 00000000000..6ea5a2fc80a --- /dev/null +++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts @@ -0,0 +1,26 @@ +import { FilteredCollection } from './filtered-collection.model'; + +export class FilteredCollections { + + public collections: Array = []; + public summary: FilteredCollection = new FilteredCollection(); + + public clear() { + this.collections.splice(0, this.collections.length); + this.summary.clear(); + } + + public deserialize(object: any) { + this.clear(); + let summary = object.summary; + this.summary.deserialize(summary); + let collections = object.collections; + for (let i = 0; i < collections.length; i++) { + let collection = collections[i]; + let coll = new FilteredCollection(); + coll.deserialize(collection); + this.collections.push(coll); + } + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts new file mode 100644 index 00000000000..2c384fe39cf --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts @@ -0,0 +1,23 @@ +import { Item } from 'src/app/core/shared/item.model'; + +export class FilteredItems { + + public items: Item[] = []; + public itemCount: number; + + public clear() { + this.items.splice(0, this.items.length); + } + + public deserialize(object: any, offset: number = 0) { + this.clear(); + this.itemCount = object.itemCount; + let items = object.items; + for (let i = 0; i < items.length; i++) { + let item = items[i]; + item.index = this.items.length + offset + 1; + this.items.push(item); + } + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html new file mode 100644 index 00000000000..4b6679bdbca --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html @@ -0,0 +1,175 @@ +
+ +
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss new file mode 100644 index 00000000000..73ce5275e5b --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss @@ -0,0 +1,3 @@ +.num { + text-align: center; +} diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items.component.spec.ts similarity index 100% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss rename to src/app/admin/admin-reports/filtered-items/filtered-items.component.spec.ts diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts new file mode 100644 index 00000000000..7fbf90565dc --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts @@ -0,0 +1,336 @@ +import { Component, ViewChild } from '@angular/core'; +import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { map, Observable } from 'rxjs'; +import { CollectionDataService } from 'src/app/core/data/collection-data.service'; +import { CommunityDataService } from 'src/app/core/data/community-data.service'; +import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service'; +import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service'; +import { RestRequestMethod } from 'src/app/core/data/rest-request-method'; +import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service'; +import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model'; +import { MetadataField } from 'src/app/core/metadata/metadata-field.model'; +import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model'; +import { Collection } from 'src/app/core/shared/collection.model'; +import { Community } from 'src/app/core/shared/community.model'; +import { Item } from 'src/app/core/shared/item.model'; +import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators'; +import { isEmpty } from 'src/app/shared/empty.util'; +import { environment } from 'src/environments/environment'; +import { FiltersComponent } from '../filters-section/filters-section.component'; +import { FilteredItems } from './filtered-items-model'; +import { OptionVO } from './option-vo.model'; +import { PresetQuery } from './preset-query.model'; +import { QueryPredicate } from './query-predicate.model'; + +/** + * Component representing the Filtered Items content report. + */ +@Component({ + selector: 'ds-report-filtered-items', + templateUrl: './filtered-items.component.html', + styleUrls: ['./filtered-items.component.scss'] +}) +export class FilteredItemsComponent { + + collections: OptionVO[]; + presetQueries: PresetQuery[]; + metadataFields: OptionVO[]; + metadataFieldsWithAny: OptionVO[]; + predicates: OptionVO[]; + pageLimits: OptionVO[]; + + queryForm: FormGroup; + currentPage = 0; + results: FilteredItems = new FilteredItems(); + results$: Observable; + @ViewChild('acc') accordionComponent: NgbAccordion; + + constructor( + private communityService: CommunityDataService, + private collectionService: CollectionDataService, + private metadataSchemaService: MetadataSchemaDataService, + private metadataFieldService: MetadataFieldDataService, + private translateService: TranslateService, + private formBuilder: FormBuilder, + private restService: DspaceRestService) {} + + ngOnInit() { + this.loadCollections(); + this.loadPresetQueries(); + this.loadMetadataFields(); + this.loadPredicates(); + this.loadPageLimits(); + + let formQueryPredicates: FormGroup[] = [ + new QueryPredicate().toFormGroup(this.formBuilder) + ]; + + this.queryForm = this.formBuilder.group({ + collections: this.formBuilder.control([''], []), + presetQuery: this.formBuilder.control('new', []), + queryPredicates: this.formBuilder.array(formQueryPredicates), + pageLimit: this.formBuilder.control('10', []), + filters: FiltersComponent.formGroup(this.formBuilder), + additionalFields: this.formBuilder.control([], []) + }); + } + + loadCollections(): void { + this.collections = []; + let wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo'); + this.collections.push(OptionVO.collectionLoc('', wholeRepo$)); + + this.communityService.findAll({ elementsPerPage: 10000, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload() + ).subscribe( + (communitiesRest: Community[]) => { + communitiesRest.forEach(community => { + let commVO = OptionVO.collection(community.uuid, community.name, true); + this.collections.push(commVO); + + this.collectionService.findByParent(community.uuid, { elementsPerPage: 10000, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload() + ).subscribe( + (collectionsRest: Collection[]) => { + collectionsRest.filter(collection => collection.firstMetadataValue('dspace.entity.type') === 'Publication') + .forEach(collection => { + let collVO = OptionVO.collection(collection.uuid, '–' + collection.name); + this.collections.push(collVO); + }); + } + ); + }); + } + ); + } + + loadPresetQueries(): void { + this.presetQueries = [ + PresetQuery.of('new', 'admin.reports.items.preset.new', []), + PresetQuery.of('q1', 'admin.reports.items.preset.hasNoTitle', [ + QueryPredicate.of('dc.title', QueryPredicate.DOES_NOT_EXIST) + ]), + PresetQuery.of('q2', 'admin.reports.items.preset.hasNoIdentifierUri', [ + QueryPredicate.of('dc.identifier.uri', QueryPredicate.DOES_NOT_EXIST) + ]), + PresetQuery.of('q3', 'admin.reports.items.preset.hasCompoundSubject', [ + QueryPredicate.of('dc.subject.*', QueryPredicate.LIKE, '%;%') + ]), + PresetQuery.of('q4', 'admin.reports.items.preset.hasCompoundAuthor', [ + QueryPredicate.of('dc.contributor.author', QueryPredicate.LIKE, '% and %') + ]), + PresetQuery.of('q5', 'admin.reports.items.preset.hasCompoundCreator', [ + QueryPredicate.of('dc.creator', QueryPredicate.LIKE, '% and %') + ]), + PresetQuery.of('q6', 'admin.reports.items.preset.hasUrlInDescription', [ + QueryPredicate.of('dc.description', QueryPredicate.MATCHES, '^.*(http://|https://|mailto:).*$') + ]), + PresetQuery.of('q7', 'admin.reports.items.preset.hasFullTextInProvenance', [ + QueryPredicate.of('dc.description.provenance', QueryPredicate.MATCHES, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$') + ]), + PresetQuery.of('q8', 'admin.reports.items.preset.hasNonFullTextInProvenance', [ + QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$') + ]), + PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [ + QueryPredicate.of('*', QueryPredicate.MATCHES, '^\s*$') + ]), + PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [ + QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*[^\s]{50,}.*$') + ]), + PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [ + QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$') + ]), + PresetQuery.of('q13', 'admin.reports.items.preset.hasNonAsciiCharInMetadata', [ + QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*[^[:ascii:]].*$') + ]) + ]; + } + + loadMetadataFields(): void { + this.metadataFields = []; + this.metadataFieldsWithAny = []; + let anyField$ = this.translateService.stream('admin.reports.items.anyField'); + this.metadataFieldsWithAny.push(OptionVO.itemLoc('*', anyField$)); + this.metadataSchemaService.findAll({ elementsPerPage: 10000, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload() + ).subscribe( + (schemasRest: MetadataSchema[]) => { + schemasRest.forEach(schema => { + this.metadataFieldService.findBySchema(schema, { elementsPerPage: 10000, currentPage: 1 }).pipe( + getFirstSucceededRemoteListPayload() + ).subscribe( + (fieldsRest: MetadataField[]) => { + fieldsRest.forEach(field => { + let fieldName = schema.prefix + '.' + field.toString(); + let fieldVO = OptionVO.item(fieldName, fieldName); + this.metadataFields.push(fieldVO); + this.metadataFieldsWithAny.push(fieldVO); + if (isEmpty(field.qualifier)) { + fieldName = schema.prefix + '.' + field.element + '.*'; + fieldVO = OptionVO.item(fieldName, fieldName); + this.metadataFieldsWithAny.push(fieldVO); + } + }); + } + ); + }); + } + ); + } + + loadPredicates(): void { + this.predicates = [ + OptionVO.item(QueryPredicate.EXISTS, 'admin.reports.items.predicate.exists'), + OptionVO.item(QueryPredicate.DOES_NOT_EXIST, 'admin.reports.items.predicate.doesNotExist'), + OptionVO.item(QueryPredicate.EQUALS, 'admin.reports.items.predicate.equals'), + OptionVO.item(QueryPredicate.DOES_NOT_EQUAL, 'admin.reports.items.predicate.doesNotEqual'), + OptionVO.item(QueryPredicate.LIKE, 'admin.reports.items.predicate.like'), + OptionVO.item(QueryPredicate.NOT_LIKE, 'admin.reports.items.predicate.notLike'), + OptionVO.item(QueryPredicate.CONTAINS, 'admin.reports.items.predicate.contains'), + OptionVO.item(QueryPredicate.DOES_NOT_CONTAIN, 'admin.reports.items.predicate.doesNotContain'), + OptionVO.item(QueryPredicate.MATCHES, 'admin.reports.items.predicate.matches'), + OptionVO.item(QueryPredicate.DOES_NOT_MATCH, 'admin.reports.items.predicate.doesNotMatch') + ]; + } + + loadPageLimits(): void { + this.pageLimits = [ + OptionVO.item('10', '10'), + OptionVO.item('25', '25'), + OptionVO.item('50', '50'), + OptionVO.item('100', '100') + ]; + } + + queryPredicatesArray(): FormArray { + return (this.queryForm.get('queryPredicates') as FormArray); + } + + addQueryPredicate(newItem: FormGroup = new QueryPredicate().toFormGroup(this.formBuilder)) { + this.queryPredicatesArray().push(newItem); + } + + deleteQueryPredicateDisabled(): boolean { + return this.queryPredicatesArray().length < 2; + } + + deleteQueryPredicate(index: number, nbToDelete: number = 1) { + if (index > -1) { + this.queryPredicatesArray().removeAt(index); + } + } + + setPresetQuery() { + let queryField = this.queryForm.controls.presetQuery as FormControl; + let value = queryField.value; + let query = this.presetQueries.find(q => q.id === value); + if (query !== undefined) { + this.queryPredicatesArray().clear(); + query.predicates + .map(qp => qp.toFormGroup(this.formBuilder)) + .forEach(qp => this.addQueryPredicate(qp)); + if (query.predicates.length === 0) { + this.addQueryPredicate(new QueryPredicate().toFormGroup(this.formBuilder)); + } + } + } + + filtersFormGroup(): FormGroup { + return this.queryForm.get('filters') as FormGroup; + } + + private pageSize() { + let form = this.queryForm.value; + return form.pageLimit; + } + + canNavigatePrevious(): boolean { + return this.currentPage > 0; + } + + prevPage() { + if (this.canNavigatePrevious()) { + this.currentPage--; + this.resubmit(); + } + } + + pageCount(): number { + let total = this.results.itemCount || 0; + return Math.ceil(total / this.pageSize()); + } + + canNavigateNext(): boolean { + return this.currentPage + 1 < this.pageCount(); + } + + nextPage() { + if (this.canNavigateNext()) { + this.currentPage++; + this.resubmit(); + } + } + + submit() { + this.accordionComponent.expand('itemResults'); + this.currentPage = 0; + this.resubmit(); + } + + resubmit() { + this.results$ = this + .getFilteredItems() + .pipe( + map(response => { + let offset = this.currentPage * this.pageSize(); + this.results.deserialize(response.payload, offset); + return this.results.items; + }) + ); + } + + getFilteredItems(): Observable { + let params = this.toQueryString(); + if (params.length > 0) { + params = `?${params}`; + } + let scheme = environment.rest.ssl ? 'https' : 'http'; + let urlRestApp = `${scheme}://${environment.rest.host}:${environment.rest.port}${environment.rest.nameSpace}`; + return this.restService.request(RestRequestMethod.GET, `${urlRestApp}/api/contentreport/filtereditems${params}`); + } + + private toQueryString(): string { + let params = `pageNumber=${this.currentPage}&pageLimit=${this.pageSize()}`; + + let colls = this.queryForm.value.collections; + for (let i = 0; i < colls.length; i++) { + params += `&collections=${colls[i]}`; + } + + let preds = this.queryForm.value.queryPredicates; + for (let i = 0; i < preds.length; i++) { + const field = preds[i].field; + const op = preds[i].operator; + const value = preds[i].value; + params += `&queryPredicates=${field}:${op}`; + if (value) { + params += `:${value}`; + } + } + + let filters = FiltersComponent.toQueryString(this.queryForm.value.filters); + if (filters.length > 0) { + params += `&${filters}`; + } + + let addFlds = this.queryForm.value.additionalFields; + for (let i = 0; i < addFlds.length; i++) { + params += `&additionalFields=${addFlds[i]}`; + } + + return params; + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts new file mode 100644 index 00000000000..0aee34d070f --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts @@ -0,0 +1,50 @@ +import { Observable } from 'rxjs'; + +/** + * Component representing an option in each selectable list of values + * used in the Filtered Items report query interface + */ +export class OptionVO { + + id: string; + name$: Observable; + disabled = false; + + static collection(id: string, name: string, disabled: boolean = false): OptionVO { + let opt = new OptionVO(); + opt.id = id; + opt.name$ = OptionVO.toObservable(name); + opt.disabled = disabled; + return opt; + } + + static collectionLoc(id: string, name$: Observable, disabled: boolean = false): OptionVO { + let opt = new OptionVO(); + opt.id = id; + opt.name$ = name$; + opt.disabled = disabled; + return opt; + } + + static item(id: string, name: string): OptionVO { + let opt = new OptionVO(); + opt.id = id; + opt.name$ = OptionVO.toObservable(name); + return opt; + } + + static itemLoc(id: string, name$: Observable): OptionVO { + let opt = new OptionVO(); + opt.id = id; + opt.name$ = name$; + return opt; + } + + private static toObservable(value: T): Observable { + return new Observable(subscriber => { + subscriber.next(value); + subscriber.complete(); + }); + + } +} diff --git a/src/app/admin/admin-reports/filtered-items/preset-query.model.ts b/src/app/admin/admin-reports/filtered-items/preset-query.model.ts new file mode 100644 index 00000000000..73522f02cf1 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/preset-query.model.ts @@ -0,0 +1,17 @@ +import { QueryPredicate } from './query-predicate.model'; + +export class PresetQuery { + + id: string; + label: string; + predicates: QueryPredicate[]; + + static of(id: string, label: string, predicates: QueryPredicate[]) { + let query = new PresetQuery(); + query.id = id; + query.label = label; + query.predicates = predicates; + return query; + } + +} diff --git a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts new file mode 100644 index 00000000000..c5f323ed2c8 --- /dev/null +++ b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts @@ -0,0 +1,36 @@ +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; + +export class QueryPredicate { + + static EXISTS = 'exists'; + static DOES_NOT_EXIST = 'doesnt_exist'; + static EQUALS = 'equals'; + static DOES_NOT_EQUAL = 'not_equals'; + static LIKE = 'like'; + static NOT_LIKE = 'not_like'; + static CONTAINS = 'contains'; + static DOES_NOT_CONTAIN = 'doesnt_contain'; + static MATCHES = 'matches'; + static DOES_NOT_MATCH = 'doesnt_match'; + + field = '*'; + operator: string; + value: string; + + static of(field: string, operator: string, value: string = '') { + let pred = new QueryPredicate(); + pred.field = field; + pred.operator = operator; + pred.value = value; + return pred; + } + + toFormGroup(formBuilder: FormBuilder): FormGroup { + return formBuilder.group({ + field: new FormControl(this.field), + operator: new FormControl(this.operator), + value: new FormControl(this.value) + }); + } + +} diff --git a/src/app/admin/admin-reports/filters-section/filter-group.model.ts b/src/app/admin/admin-reports/filters-section/filter-group.model.ts new file mode 100644 index 00000000000..975b43a9860 --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filter-group.model.ts @@ -0,0 +1,19 @@ +import { Filter } from './filter.model'; + +export class FilterGroup { + + id: string; + key: string; + + constructor(id: string, public filters: Filter[]) { + this.id = id; + this.key = 'admin.reports.commons.filters.' + id; + filters.forEach(filter => { + filter.key = this.key + '.' + filter.id; + if (filter.hasTooltip) { + filter.tooltipKey = filter.key + '.tooltip'; + } + }); + } + +} diff --git a/src/app/admin/admin-reports/filters-section/filter.model.ts b/src/app/admin/admin-reports/filters-section/filter.model.ts new file mode 100644 index 00000000000..63eeb114cde --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filter.model.ts @@ -0,0 +1,8 @@ +export class Filter { + + key: string; + tooltipKey: string; + + constructor(public id: string, public hasTooltip: boolean = false) {} + +} diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.html b/src/app/admin/admin-reports/filters-section/filters-section.component.html new file mode 100644 index 00000000000..1e7856f09cb --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filters-section.component.html @@ -0,0 +1,19 @@ +
+ +   +   + +   + +   +   + +
+
+ {{group.key | translate}} + +
+ +
+
+
diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss b/src/app/admin/admin-reports/filters-section/filters-section.component.scss similarity index 100% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss rename to src/app/admin/admin-reports/filters-section/filters-section.component.scss diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts b/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts new file mode 100644 index 00000000000..94f2753ec09 --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts @@ -0,0 +1,101 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { FiltersComponent } from './filters-section.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock'; +import { FormBuilder } from '@angular/forms'; + +describe('FiltersComponent', () => { + let component: FiltersComponent; + let fixture: ComponentFixture; + let formBuilder: FormBuilder; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [FiltersComponent], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + FormBuilder + ], + schemas: [NO_ERRORS_SCHEMA] + }); + })); + + beforeEach(waitForAsync(() => { + formBuilder = TestBed.inject(FormBuilder); + + fixture = TestBed.createComponent(FiltersComponent); + component = fixture.componentInstance; + component.filtersForm = FiltersComponent.formGroup(formBuilder); + fixture.detectChanges(); + })); + + const isOneSelected = (values: {}): boolean => { + let oneSelected = false; + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; !oneSelected && i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + oneSelected = oneSelected || values[filter.id]; + } + } + return oneSelected; + }; + + const isAllSelected = (values: {}): boolean => { + let allSelected = true; + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; allSelected && i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + allSelected = allSelected && values[filter.id]; + } + } + return allSelected; + }; + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should select all checkboxes', () => { + // By default, nothing is selected, so at least one item is not selected. + let values = component.filtersForm.value; + let allSelected: boolean = isAllSelected(values); + expect(allSelected).toBeFalse(); + + // Now we select everything... + component.selectAll(); + + // We must retrieve the form values again since selectAll() injects a new dictionary. + values = component.filtersForm.value; + allSelected = isAllSelected(values); + expect(allSelected).toBeTrue(); + }); + + it('should deselect all checkboxes', () => { + // Since nothing is selected by default, we select at least an item + // so that deselectAll() actually deselects something. + let values = component.filtersForm.value; + values.is_item = true; + let oneSelected: boolean = isOneSelected(values); + expect(oneSelected).toBeTrue(); + + // Now we deselect everything... + component.deselectAll(); + + // We must retrieve the form values again since deselectAll() injects a new dictionary. + values = component.filtersForm.value; + oneSelected = isOneSelected(values); + expect(oneSelected).toBeFalse(); + }); +}); diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.ts b/src/app/admin/admin-reports/filters-section/filters-section.component.ts new file mode 100644 index 00000000000..d5dc074f089 --- /dev/null +++ b/src/app/admin/admin-reports/filters-section/filters-section.component.ts @@ -0,0 +1,149 @@ +import { Component, Input } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { FilterGroup } from './filter-group.model'; +import { Filter } from './filter.model'; + +/** + * Component representing the Query Filters section used in both + * Filtered Collections and Filtered Items content reports + */ +@Component({ + selector: 'ds-filters', + templateUrl: './filters-section.component.html', + styleUrls: ['./filters-section.component.scss'] +}) +export class FiltersComponent { + + static FILTERS = [ + new FilterGroup('property', [ + new Filter('is_item'), + new Filter('is_withdrawn'), + new Filter('is_not_withdrawn'), + new Filter('is_discoverable'), + new Filter('is_not_discoverable') + ]), + new FilterGroup('bitstream', [ + new Filter('has_multiple_originals'), + new Filter('has_no_originals'), + new Filter('has_one_original') + ]), + new FilterGroup('bitstream_mime', [ + new Filter('has_doc_original'), + new Filter('has_image_original'), + new Filter('has_unsupp_type'), + new Filter('has_mixed_original'), + new Filter('has_pdf_original'), + new Filter('has_jpg_original'), + new Filter('has_small_pdf'), + new Filter('has_large_pdf'), + new Filter('has_doc_without_text') + ]), + new FilterGroup('mime', [ + new Filter('has_only_supp_image_type'), + new Filter('has_unsupp_image_type'), + new Filter('has_only_supp_doc_type'), + new Filter('has_unsupp_doc_type') + ]), + new FilterGroup('bundle', [ + new Filter('has_unsupported_bundle'), + new Filter('has_small_thumbnail'), + new Filter('has_original_without_thumbnail'), + new Filter('has_invalid_thumbnail_name'), + new Filter('has_non_generated_thumb'), + new Filter('no_license'), + new Filter('has_license_documentation') + ]), + new FilterGroup('permission', [ + new Filter('has_restricted_original', true), + new Filter('has_restricted_thumbnail', true), + new Filter('has_restricted_metadata', true) + ]) + ]; + + @Input() filtersForm: FormGroup; + + static formGroup(formBuilder: FormBuilder): FormGroup { + let fields = {}; + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + fields[filter.id] = new FormControl(false); + } + } + return formBuilder.group(fields); + } + + static getFilter(filterId: string): Filter { + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + if (filter.id === filterId) { + return filter; + } + } + } + return undefined; + } + + static getGroup(filterId: string): FilterGroup { + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + if (filter.id === filterId) { + return group; + } + } + } + return undefined; + } + + static toQueryString(filters: Object): string { + let params = ''; + let first = true; + for (const key in filters) { + if (filters[key]) { + if (first) { + first = false; + } else { + params += '&'; + } + params += `filters=${key}`; + } + } + return params; + } + + allFilters(): FilterGroup[] { + return FiltersComponent.FILTERS; + } + + private setAllFilters(value: boolean) { + // I don't know why, but patchValue() with individual controls doesn't work. + // I therefore use setValue() with the whole set, which mercifully works... + let fields = {}; + let allFilters = FiltersComponent.FILTERS; + for (let i = 0; i < allFilters.length; i++) { + let group = allFilters[i]; + for (let j = 0; j < group.filters.length; j++) { + let filter = group.filters[j]; + fields[filter.id] = value; + } + } + this.filtersForm.setValue(fields); + } + + selectAll(): void { + this.setAllFilters(true); + } + + deselectAll(): void { + this.setAllFilters(false); + } + +} diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 30f801cecb7..9c4c6eb15a2 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -1,13 +1,30 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAdminModuleRoute } from '../app-routing-paths'; +import { getQualityAssuranceEditRoute } from './admin-notifications/admin-notifications-routing-paths'; export const REGISTRIES_MODULE_PATH = 'registries'; export const NOTIFICATIONS_MODULE_PATH = 'notifications'; +export const LDN_PATH = 'ldn'; +export const REPORTS_MODULE_PATH = 'reports'; + + export function getRegistriesModuleRoute() { return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString(); } +export function getLdnServicesModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), LDN_PATH).toString(); +} + export function getNotificationsModuleRoute() { return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString(); } + +export function getNotificatioQualityAssuranceRoute() { + return new URLCombiner(`/${NOTIFICATIONS_MODULE_PATH}`, getQualityAssuranceEditRoute()).toString(); +} + +export function getReportsModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), REPORTS_MODULE_PATH).toString(); +} diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index a7d19a69357..ae7d49a9150 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,8 +6,15 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; -import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths'; +import { + LDN_PATH, + NOTIFICATIONS_MODULE_PATH, + REGISTRIES_MODULE_PATH, REPORTS_MODULE_PATH, +} from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; +import { + SiteAdministratorGuard +} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; @NgModule({ imports: [ @@ -21,42 +28,65 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import path: REGISTRIES_MODULE_PATH, loadChildren: () => import('./admin-registries/admin-registries.module') .then((m) => m.AdminRegistriesModule), + canActivate: [SiteAdministratorGuard] }, { path: 'search', resolve: { breadcrumb: I18nBreadcrumbResolver }, component: AdminSearchPageComponent, - data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' } + data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' }, + canActivate: [SiteAdministratorGuard] }, { path: 'workflow', resolve: { breadcrumb: I18nBreadcrumbResolver }, component: AdminWorkflowPageComponent, - data: { title: 'admin.workflow.title', breadcrumbKey: 'admin.workflow' } + data: { title: 'admin.workflow.title', breadcrumbKey: 'admin.workflow' }, + canActivate: [SiteAdministratorGuard] }, { path: 'curation-tasks', resolve: { breadcrumb: I18nBreadcrumbResolver }, component: AdminCurationTasksComponent, - data: { title: 'admin.curation-tasks.title', breadcrumbKey: 'admin.curation-tasks' } + data: { title: 'admin.curation-tasks.title', breadcrumbKey: 'admin.curation-tasks' }, + canActivate: [SiteAdministratorGuard] }, { path: 'metadata-import', resolve: { breadcrumb: I18nBreadcrumbResolver }, component: MetadataImportPageComponent, - data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } + data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' }, + canActivate: [SiteAdministratorGuard] }, { path: 'batch-import', resolve: { breadcrumb: I18nBreadcrumbResolver }, component: BatchImportPageComponent, - data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } + data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }, + canActivate: [SiteAdministratorGuard] }, { path: 'system-wide-alert', resolve: { breadcrumb: I18nBreadcrumbResolver }, loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule), - data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'} + data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}, + canActivate: [SiteAdministratorGuard] + }, + { + path: LDN_PATH, + children: [ + { path: '', pathMatch: 'full', redirectTo: 'services' }, + { + path: 'services', + loadChildren: () => import('./admin-ldn-services/admin-ldn-services.module') + .then((m) => m.AdminLdnServicesModule), + } + ], + }, + { + path: REPORTS_MODULE_PATH, + loadChildren: () => import('./admin-reports/admin-reports.module') + .then((m) => m.AdminReportsModule), }, ]) ], diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html index c4b737849b6..639c47f7f8c 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts index dab6694f368..0e41a20d847 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentFactoryResolver, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ComponentRef, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { @@ -11,9 +11,10 @@ import { SearchResultGridElementComponent } from '../../../../../shared/object-g import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; -import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { DynamicComponentLoaderDirective } from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { hasValue } from '../../../../../shared/empty.util'; @listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch) @Component({ @@ -24,17 +25,18 @@ import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service /** * The component for displaying a list element for an item search result on the admin search page */ -export class ItemAdminSearchResultGridElementComponent extends SearchResultGridElementComponent implements OnInit { - @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; +export class ItemAdminSearchResultGridElementComponent extends SearchResultGridElementComponent implements OnDestroy, OnInit { + @ViewChild(DynamicComponentLoaderDirective, { static: true }) dynamicComponentLoaderDirective: DynamicComponentLoaderDirective; @ViewChild('badges', { static: true }) badges: ElementRef; @ViewChild('buttons', { static: true }) buttons: ElementRef; + protected compRef: ComponentRef; + constructor( public dsoNameService: DSONameService, protected truncatableService: TruncatableService, protected bitstreamDataService: BitstreamDataService, private themeService: ThemeService, - private componentFactoryResolver: ComponentFactoryResolver, ) { super(dsoNameService, truncatableService, bitstreamDataService); } @@ -44,23 +46,32 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE */ ngOnInit(): void { super.ngOnInit(); - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent()); + const component: GenericConstructor = this.getComponent(); - const viewContainerRef = this.listableObjectDirective.viewContainerRef; + const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; viewContainerRef.clear(); - const componentRef = viewContainerRef.createComponent( - componentFactory, - 0, - undefined, - [ - [this.badges.nativeElement], - [this.buttons.nativeElement] - ]); - (componentRef.instance as any).object = this.object; - (componentRef.instance as any).index = this.index; - (componentRef.instance as any).linkType = this.linkType; - (componentRef.instance as any).listID = this.listID; + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + projectableNodes: [ + [this.badges.nativeElement], + [this.buttons.nativeElement], + ], + }, + ); + this.compRef.setInput('object',this.object); + this.compRef.setInput('index', this.index); + this.compRef.setInput('linkType', this.linkType); + this.compRef.setInput('listID', this.listID); + } + + ngOnDestroy(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = undefined; + } } /** diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html index 7f1e8716ba5..24ba17fff47 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -1,23 +1,21 @@ -